UNPKG

@corvu/resizable

Version:

Unstyled, accessible and customizable UI primitives for SolidJS

1,523 lines (1,512 loc) 55.5 kB
// src/context.ts import { createContext, useContext } from "solid-js"; import { createKeyedContext, useKeyedContext } from "@corvu/utils/create/keyedContext"; var ResizableContext = createContext(); var createResizableContext = (contextId) => { if (contextId === void 0) return ResizableContext; const context = createKeyedContext( `resizable-${contextId}` ); return context; }; var useResizableContext = (contextId) => { if (contextId === void 0) { const context2 = useContext(ResizableContext); if (!context2) { throw new Error( "[corvu]: Resizable context not found. Make sure to wrap Resizable components in <Resizable.Root>" ); } return context2; } const context = useKeyedContext( `resizable-${contextId}` ); if (!context) { throw new Error( `[corvu]: Resizable context with id "${contextId}" not found. Make sure to wrap Resizable components in <Resizable.Root contextId="${contextId}">` ); } return context; }; var InternalResizableContext = createContext(); var createInternalResizableContext = (contextId) => { if (contextId === void 0) return InternalResizableContext; const context = createKeyedContext( `resizable-internal-${contextId}` ); return context; }; var useInternalResizableContext = (contextId) => { if (contextId === void 0) { const context2 = useContext(InternalResizableContext); if (!context2) { throw new Error( "[corvu]: Resizable context not found. Make sure to wrap Resizable components in <Resizable.Root>" ); } return context2; } const context = useKeyedContext( `resizable-internal-${contextId}` ); if (!context) { throw new Error( `[corvu]: Resizable context with id "${contextId}" not found. Make sure to wrap Resizable components in <Resizable.Root contextId="${contextId}">` ); } return context; }; // src/Handle.tsx import { callEventHandler, combineStyle } from "@corvu/utils/dom"; import { createEffect, createMemo, createSignal, mergeProps, on, onCleanup, Show, splitProps } from "solid-js"; import { Dynamic } from "@corvu/utils/dynamic"; // src/lib/utils.ts var resolveSize = (size, rootSize) => { if (typeof size === "number") { return size; } if (!size.endsWith("px")) { throw new Error( `[corvu] Sizes must be a number or a string ending with 'px'. Got ${size}` ); } return fixToPrecision(parseFloat(size) / rootSize); }; var splitPanels = (props) => { const precedingPanels = props.panels.filter( (panel) => !!(props.focusedElement.compareDocumentPosition(panel.data.element) & Node.DOCUMENT_POSITION_PRECEDING) ); const followingPanels = props.panels.filter( (panel) => !!(props.focusedElement.compareDocumentPosition(panel.data.element) & Node.DOCUMENT_POSITION_FOLLOWING) ); return [precedingPanels, followingPanels]; }; var PRECISION = 6; var fixToPrecision = (value) => parseFloat(value.toFixed(PRECISION)); // src/lib/cursor.ts var globalCursorStyle = null; var cursorStyleElement = null; var globalResizeConstraints = 0; var constraintToCursorMap = { 1: "e-resize", 2: "w-resize", 3: "ew-resize", 4: "s-resize", 8: "n-resize", 12: "ns-resize", 5: "se-resize", 9: "ne-resize", 6: "sw-resize", 10: "nw-resize" }; var cachedCursorStyle = null; var updateCursorStyle = () => { if (!globalCursorStyle) { if (cursorStyleElement) { cachedCursorStyle = null; cursorStyleElement.remove(); cursorStyleElement = null; } return; } let cursorStyle = constraintToCursorMap[globalResizeConstraints] ?? null; if (cursorStyle === null) { switch (globalCursorStyle) { case "horizontal": cursorStyle = "col-resize"; break; case "vertical": cursorStyle = "row-resize"; break; case "both": cursorStyle = "move"; break; } } if (cursorStyle === cachedCursorStyle) return; cachedCursorStyle = cursorStyle; if (!cursorStyleElement) { cursorStyleElement = document.createElement("style"); document.head.appendChild(cursorStyleElement); } cursorStyleElement.innerHTML = `*{cursor: ${cursorStyle}!important;}`; }; var reportResizeConstraints = (orientation, constraints) => { switch (orientation) { case "horizontal": if (constraints === 1) { globalResizeConstraints |= 1; globalResizeConstraints &= ~2; } else if (constraints === 2) { globalResizeConstraints |= 2; globalResizeConstraints &= ~1; } else if (constraints === 3) { globalResizeConstraints |= 3; } else { globalResizeConstraints &= ~3; } break; case "vertical": if (constraints === 1) { globalResizeConstraints |= 4; globalResizeConstraints &= ~8; } else if (constraints === 2) { globalResizeConstraints |= 8; globalResizeConstraints &= ~4; } else if (constraints === 3) { globalResizeConstraints |= 12; } else { globalResizeConstraints &= ~12; } break; } updateCursorStyle(); }; var resetResizeConstraints = () => { globalResizeConstraints = 0; updateCursorStyle(); }; var setGlobalCursorStyle = (cursorStyle) => { globalCursorStyle = cursorStyle; updateCursorStyle(); }; var handleResizeConstraints = (props) => { if (fixToPrecision(props.distributablePercentage) !== fixToPrecision(props.desiredPercentage)) { let constraints = null; if (props.betweenCollapse === true) { constraints = 3; } else if (props.desiredPercentage < props.distributablePercentage && props.revertConstraints !== true || props.desiredPercentage > props.distributablePercentage && props.revertConstraints === true) { constraints = 1; } else { constraints = 2; } reportResizeConstraints(props.orientation, constraints); } else { reportResizeConstraints(props.orientation, 0); } }; // src/lib/handleManager.ts import { batch } from "solid-js"; import { some } from "@corvu/utils/reactivity"; var handles = []; var dragStartPos = null; var globalHovered = null; var INTERSECTION_TOLERANCE = 1; var equalsWithTolerance = (a, b) => Math.abs(a - b) <= INTERSECTION_TOLERANCE; var registerHandle = (handle) => { handles.push(handle); for (const handle2 of handles) { for (const compareHandle of handles) { if (handle2.orientation === compareHandle.orientation || handle2.element === compareHandle.element) { continue; } const handleRect = handle2.element.getBoundingClientRect(); const compareHandleRect = compareHandle.element.getBoundingClientRect(); if (handle2.orientation === "horizontal") { if (handleRect.left > compareHandleRect.right || handleRect.right < compareHandleRect.left) { continue; } } if (handle2.orientation === "vertical") { if (handleRect.top > compareHandleRect.bottom || handleRect.bottom < compareHandleRect.top) { continue; } } const isStartIntersection = handle2.orientation === "horizontal" ? equalsWithTolerance(handleRect.top, compareHandleRect.bottom) : equalsWithTolerance(handleRect.left, compareHandleRect.right); const isEndIntersection = handle2.orientation === "horizontal" ? equalsWithTolerance(handleRect.bottom, compareHandleRect.top) : equalsWithTolerance(handleRect.right, compareHandleRect.left); if (isStartIntersection) { handle2.startIntersection.setHandle(compareHandle); } if (isEndIntersection) { handle2.endIntersection.setHandle(compareHandle); } } } return { onDragStart: (event, target) => onDragStart(handle, event, target), onHoveredChange: (state) => { globalHovered = state; const dragging = !!dragStartPos; let cursorStyle = null; batch(() => { switch (state) { case "handle": { const startHandle = handle.startIntersection.handle(); const endHandle = handle.endIntersection.handle(); if (!dragging) { handle.setActive(true); startHandle?.setActive(false); endHandle?.setActive(false); } startHandle?.setHoveredAsIntersection(false); endHandle?.setHoveredAsIntersection(false); cursorStyle = handle.orientation; break; } case "startIntersection": { const startHandle = handle.startIntersection.handle(); if (!dragging) { startHandle?.setActive(true); } startHandle?.setHoveredAsIntersection(true); cursorStyle = "both"; break; } case "endIntersection": { const endHandle = handle.endIntersection.handle(); if (!dragging) { endHandle?.setActive(true); } endHandle?.setHoveredAsIntersection(true); cursorStyle = "both"; break; } case null: { const startHandle = handle.startIntersection.handle(); const endHandle = handle.endIntersection.handle(); if (!dragging && !handle.focused()) { handle.setActive(false); startHandle?.setActive(false); endHandle?.setActive(false); } startHandle?.setHoveredAsIntersection(false); endHandle?.setHoveredAsIntersection(false); cursorStyle = null; break; } } if (!dragging && handle.handleCursorStyle()) { setGlobalCursorStyle(cursorStyle); } }); } }; }; var unregisterHandle = (handle) => { handles.splice(handles.indexOf(handle), 1); }; var onDragStart = (handle, event, target) => { dragStartPos = { x: event.clientX, y: event.clientY }; batch(() => { handle.setDragging(true); if (target === "startIntersection") { handle.startIntersection.handle()?.setDragging(true); } if (target === "endIntersection") { handle.endIntersection.handle()?.setDragging(true); } }); window.addEventListener("pointermove", onPointerMove); window.addEventListener("touchmove", onTouchMove); window.addEventListener("pointerup", onDragEnd); window.addEventListener("touchend", onDragEnd); window.addEventListener("contextmenu", onDragEnd); }; var onPointerMove = (event) => onMove(event.clientX, event.clientY, event.altKey); var onTouchMove = (event) => { if (!event.touches[0]) return; onMove(event.touches[0].clientX, event.touches[0].clientY, event.altKey); }; var altKeyCache = false; var onMove = (x, y, altKey) => { if (!dragStartPos) return; if (handles.some((handle) => handle.dragging() && handle.altKey === "only")) { altKey = true; } if (handles.some((handle) => handle.dragging() && handle.altKey === false)) { altKey = false; } if (altKeyCache !== altKey) { dragStartPos = { x, y }; altKeyCache = altKey; } for (const handle of handles) { if (!handle.dragging()) continue; handle.onDrag( handle.orientation === "horizontal" ? x - dragStartPos.x : y - dragStartPos.y, altKey ); } }; var onDragEnd = (event) => { batch(() => { for (const handle of handles) { if (!handle.dragging()) { if (some(handle.hovered, handle.focused, handle.hoveredAsIntersection)) { handle.setActive(true); if (handle.handleCursorStyle()) { setGlobalCursorStyle(handle.orientation); } } } else { handle.setDragging(false); handle.onDragEnd(event); if (!handle.hovered() && !handle.hoveredAsIntersection()) { handle.setActive(false); } if (!globalHovered && handle.handleCursorStyle()) { setGlobalCursorStyle(null); } } } }); resetResizeConstraints(); dragStartPos = null; window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("touchmove", onTouchMove); window.removeEventListener("pointerup", onDragEnd); window.removeEventListener("touchend", onDragEnd); window.removeEventListener("contextmenu", onDragEnd); }; // src/Handle.tsx import { dataIf } from "@corvu/utils"; import { mergeRefs } from "@corvu/utils/reactivity"; var ResizableHandle = (props) => { const defaultedProps = mergeProps( { startIntersection: true, endIntersection: true, altKey: true }, props ); const [localProps, otherProps] = splitProps(defaultedProps, [ "startIntersection", "endIntersection", "altKey", "onHandleDragStart", "onHandleDrag", "onHandleDragEnd", "contextId", "ref", "style", "disabled", "children", "onMouseEnter", "onMouseLeave", "onKeyDown", "onKeyUp", "onFocus", "onBlur", "onPointerDown" ]); const [ref, setRef] = createSignal(null); const [hoveredAsIntersection, setHoveredAsIntersection] = createSignal(false); const [hovered, setHovered] = createSignal(null); const [focused, setFocused] = createSignal(false); const [active, setActive] = createSignal(false); const [dragging, setDragging] = createSignal(false); const [startIntersection, setStartIntersection] = createSignal( null ); const [endIntersection, setEndIntersection] = createSignal( null ); const context = createMemo( () => useInternalResizableContext(localProps.contextId) ); const ariaInformation = createMemo(() => { const handle = ref(); if (!handle) { return void 0; } const panels = context().panels(); const [precidingPanels, followingPanels] = splitPanels({ panels, focusedElement: handle }); const ariaControls = precidingPanels[precidingPanels.length - 1]?.data.id; const ariaValueMax = followingPanels.reduce( (acc, panel) => acc - resolveSize(panel.data.minSize, context().rootSize()), 1 ); const ariaValueMin = precidingPanels.reduce( (acc, panel) => acc + resolveSize(panel.data.minSize, context().rootSize()), 0 ); const ariaValueNow = precidingPanels.reduce( (acc, panel) => acc + resolveSize(panel.size(), context().rootSize()), 0 ); return { ariaControls, ariaValueMax: fixToPrecision(ariaValueMax), ariaValueMin: fixToPrecision(ariaValueMin), ariaValueNow: fixToPrecision(ariaValueNow) }; }); let globalHandleCallbacks = null; createEffect(() => { if (localProps.disabled === true) return; const element = ref(); if (!element) return; const globalHandle = { element, orientation: context().orientation(), handleCursorStyle: context().handleCursorStyle, altKey: localProps.altKey, startIntersection: { handle: startIntersection, setHandle: (handle) => { if (localProps.startIntersection !== true) return; setStartIntersection(handle); } }, endIntersection: { handle: endIntersection, setHandle: (handle) => { if (localProps.endIntersection !== true) return; setEndIntersection(handle); } }, hovered, focused, hoveredAsIntersection, setHoveredAsIntersection, active, setActive, dragging, setDragging, onDrag: (delta, altKey) => { if (localProps.onHandleDrag !== void 0) { const dragEvent = new CustomEvent("drag", { cancelable: true }); localProps.onHandleDrag(dragEvent); if (dragEvent.defaultPrevented) return; } context().onDrag(element, delta, altKey); }, onDragEnd: (event) => { localProps.onHandleDragEnd?.(event); context().onDragEnd(); } }; globalHandleCallbacks = registerHandle(globalHandle); onCleanup(() => { unregisterHandle(globalHandle); globalHandleCallbacks = null; }); }); createEffect( on(hovered, () => { globalHandleCallbacks?.onHoveredChange(hovered()); }) ); const onMouseEnter = (e) => { if (callEventHandler(localProps.onMouseEnter, e) || localProps.disabled === true) return; setHovered("handle"); }; const onMouseLeave = (e) => { if (callEventHandler(localProps.onMouseLeave, e)) return; setHovered(null); }; const onKeyDown = (e) => { if (callEventHandler(localProps.onKeyDown, e) || dragging()) return; const element = ref(); if (!element) return; const altKey = localProps.altKey === "only" || localProps.altKey !== false && e.altKey; context().onKeyDown(element, e, altKey); }; const onKeyUp = (e) => { if (callEventHandler(localProps.onKeyUp, e) || e.key !== "Tab") return; setFocused(true); }; const onFocus = (e) => { if (callEventHandler(localProps.onFocus, e) || hovered()) return; setFocused(true); setActive(true); }; const onBlur = (e) => { if (callEventHandler(localProps.onBlur, e)) return; setFocused(false); if (hovered()) return; setActive(false); }; const onPointerDown = (e) => { if (callEventHandler(localProps.onPointerDown, e)) return; if (callEventHandler(localProps.onHandleDragStart, e)) return; const targetElement = e.target; let target = "handle"; if (targetElement.hasAttribute( "data-corvu-resizable-handle-start-intersection" )) { target = "startIntersection"; } if (targetElement.hasAttribute("data-corvu-resizable-handle-end-intersection")) { target = "endIntersection"; } globalHandleCallbacks?.onDragStart(e, target); }; return <Dynamic as="button" ref={mergeRefs(setRef, localProps.ref)} style={combineStyle( { position: "relative", cursor: context().handleCursorStyle() ? "inherit" : void 0, "touch-action": "none", "flex-shrink": 0 }, localProps.style )} disabled={localProps.disabled} onBlur={onBlur} onFocus={onFocus} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onPointerDown={onPointerDown} role="separator" aria-controls={ariaInformation()?.ariaControls} aria-orientation={context().orientation()} aria-valuemax={ariaInformation()?.ariaValueMax} aria-valuemin={ariaInformation()?.ariaValueMin} aria-valuenow={ariaInformation()?.ariaValueNow} data-active={dataIf(active())} data-dragging={dataIf(dragging())} data-orientation={context().orientation()} data-corvu-resizable-handle="" {...otherProps} > <Show when={startIntersection()}> <div data-corvu-resizable-handle-start-intersection onMouseEnter={() => setHovered("startIntersection")} onMouseLeave={(e) => { if (ref()?.contains(e.relatedTarget) === true) { setHovered("handle"); } else { setHovered(null); } }} style={{ position: "absolute", "aspect-ratio": "1 / 1", top: 0, left: 0, height: context().orientation() === "horizontal" ? void 0 : "100%", width: context().orientation() === "horizontal" ? "100%" : void 0, transform: context().orientation() === "horizontal" ? "translate3d(0, -100%, 0)" : "translate3d(-100%, 0, 0)", "z-index": 1 }} /> </Show> {localProps.children} <Show when={endIntersection()}> <div data-corvu-resizable-handle-end-intersection onMouseEnter={() => setHovered("endIntersection")} onMouseLeave={(e) => { if (ref()?.contains(e.relatedTarget) === true) { setHovered("handle"); } else { setHovered(null); } }} style={{ position: "absolute", "aspect-ratio": "1 / 1", bottom: 0, right: 0, height: context().orientation() === "horizontal" ? void 0 : "100%", width: context().orientation() === "horizontal" ? "100%" : void 0, transform: context().orientation() === "horizontal" ? "translate3d(0, 100%, 0)" : "translate3d(100%, 0, 0)", "z-index": 1 }} /> </Show> </Dynamic>; }; var Handle_default = ResizableHandle; // src/Panel.tsx import { combineStyle as combineStyle2 } from "@corvu/utils/dom"; import { createEffect as createEffect2, createMemo as createMemo2, createSignal as createSignal2, createUniqueId, mergeProps as mergeProps2, onCleanup as onCleanup2, splitProps as splitProps2, untrack } from "solid-js"; import { dataIf as dataIf2, isFunction } from "@corvu/utils"; import { Dynamic as Dynamic2 } from "@corvu/utils/dynamic"; import createOnce from "@corvu/utils/create/once"; // src/panelContext.ts import { createContext as createContext2, useContext as useContext2 } from "solid-js"; import { createKeyedContext as createKeyedContext2, useKeyedContext as useKeyedContext2 } from "@corvu/utils/create/keyedContext"; var ResizablePanelContext = createContext2(); var createResizablePanelContext = (contextId) => { if (contextId === void 0) return ResizablePanelContext; const context = createKeyedContext2( `resizable-panel-${contextId}` ); return context; }; var useResizablePanelContext = (contextId) => { if (contextId === void 0) { const context2 = useContext2(ResizablePanelContext); if (!context2) { throw new Error( "[corvu]: Resizable panel context not found. Make sure to call usePanelContext under <Resizable.Panel>" ); } return context2; } const context = useKeyedContext2( `resizable-panel-${contextId}` ); if (!context) { throw new Error( `[corvu]: Resizable context with id "${contextId}" not found. Make sure to call usePanelContext under <Resizable.Panel contextId="${contextId}">` ); } return context; }; // src/Panel.tsx import { mergeRefs as mergeRefs2 } from "@corvu/utils/reactivity"; var ResizablePanel = (props) => { const defaultedProps = mergeProps2( { initialSize: null, minSize: 0, maxSize: 1, collapsible: false, collapsedSize: 0, collapseThreshold: 0.05, panelId: createUniqueId() }, props ); const [localProps, otherProps] = splitProps2(defaultedProps, [ "initialSize", "minSize", "maxSize", "collapsible", "collapsedSize", "collapseThreshold", "onResize", "onCollapse", "onExpand", "contextId", "panelId", "ref", "style", "children" ]); const [ref, setRef] = createSignal2(null); const context = createMemo2( () => useInternalResizableContext(localProps.contextId) ); const [panelInstance, setPanelInstance] = createSignal2( null ); createEffect2(() => { const element = ref(); if (!element) return; const _context = context(); const instance = untrack(() => { return _context.registerPanel({ id: localProps.panelId, element, initialSize: localProps.initialSize, minSize: localProps.minSize, maxSize: localProps.maxSize, collapsible: localProps.collapsible, collapsedSize: localProps.collapsedSize, collapseThreshold: localProps.collapseThreshold, onResize: localProps.onResize }); }); setPanelInstance(instance); onCleanup2(() => { _context.unregisterPanel(instance.data.id); }); }); const panelSize = () => { const instance = panelInstance(); if (!instance) { if (typeof localProps.initialSize === "number") { return localProps.initialSize; } return 1; } return instance.size(); }; const collapsed = createMemo2((prev) => { const instance = panelInstance(); if (localProps.collapsible !== true) { return false; } const collapsed2 = instance ? instance.size() === resolveSize(localProps.collapsedSize, context().rootSize()) : false; if (instance && prev !== collapsed2) { if (collapsed2 && localProps.onCollapse !== void 0) { localProps.onCollapse(instance.size()); } else if (!collapsed2 && localProps.onExpand !== void 0) { localProps.onExpand(instance.size()); } } return collapsed2; }); const resize2 = (size, strategy) => { const instance = panelInstance(); if (!instance) { return; } instance.resize(size, strategy ?? "both"); }; const collapse = (strategy) => { const instance = panelInstance(); if (!instance) { return; } instance.collapse(strategy ?? "both"); }; const expand = (strategy) => { const instance = panelInstance(); if (!instance) { return; } instance.expand(strategy ?? "both"); }; const childrenProps = { get size() { return panelSize(); }, get minSize() { return localProps.minSize; }, get maxSize() { return localProps.maxSize; }, get collapsible() { return localProps.collapsible; }, get collapsedSize() { return localProps.collapsedSize; }, get collapseThreshold() { return localProps.collapseThreshold; }, get collapsed() { return collapsed(); }, resize: resize2, collapse, expand, get panelId() { return localProps.panelId; } }; const memoizedChildren = createOnce(() => localProps.children); const resolveChildren = () => { const children = memoizedChildren()(); if (isFunction(children)) { return children(childrenProps); } return children; }; const memoizedResizablePanel = createMemo2(() => { const ResizablePanelContext2 = createResizablePanelContext( localProps.contextId ); return <ResizablePanelContext2.Provider value={{ size: panelSize, minSize: () => localProps.minSize, maxSize: () => localProps.maxSize, collapsible: () => localProps.collapsible, collapsedSize: () => localProps.collapsedSize, collapseThreshold: () => localProps.collapseThreshold, collapsed, resize: resize2, collapse, expand, panelId: () => localProps.panelId }} > <Dynamic2 as="div" ref={mergeRefs2(setRef, localProps.ref)} style={combineStyle2( { "flex-basis": panelSize() * 100 + "%" }, localProps.style )} id={localProps.panelId} data-collapsed={dataIf2(collapsed())} data-expanded={dataIf2( localProps.collapsible === true && !collapsed() )} data-orientation={context().orientation()} data-corvu-resizable-panel="" {...otherProps} > {untrack(() => resolveChildren())} </Dynamic2> </ResizablePanelContext2.Provider>; }); return memoizedResizablePanel; }; var Panel_default = ResizablePanel; // src/Root.tsx import { combineStyle as combineStyle3, sortByDocumentPosition } from "@corvu/utils/dom"; import { createEffect as createEffect3, createMemo as createMemo3, createSignal as createSignal3, mergeProps as mergeProps3, splitProps as splitProps3, untrack as untrack2 } from "solid-js"; // src/lib/resize.ts import "solid-js"; var getDistributablePercentage = (props) => { let distributablePercentage = props.desiredPercentage >= 0 ? Infinity : -Infinity; let newSizes = props.initialSizes; for (const resizeAction of props.resizeActions) { const desiredPercentage = resizeAction.negate !== true ? props.desiredPercentage : -props.desiredPercentage; let [ distributedPercentagePreceding, // eslint-disable-next-line prefer-const distributedSizesPreceding, // eslint-disable-next-line prefer-const collapsedPreceding ] = distributePercentage({ desiredPercentage, side: "preceding", panels: resizeAction.precedingPanels, initialSizes: newSizes, initialSizesStartIndex: 0, collapsible: props.collapsible, rootSize: props.resizableData.rootSize }); let [ distributedPercentageFollowing, // eslint-disable-next-line prefer-const distributedSizesFollowing, // eslint-disable-next-line prefer-const collapsedFollowing ] = distributePercentage({ desiredPercentage, side: "following", panels: resizeAction.followingPanels, initialSizes: newSizes, initialSizesStartIndex: resizeAction.precedingPanels.length, collapsible: props.collapsible, rootSize: props.resizableData.rootSize }); if (resizeAction.negate === true) { distributedPercentagePreceding = -distributedPercentagePreceding; distributedPercentageFollowing = -distributedPercentageFollowing; } if (collapsedPreceding) { distributedPercentageFollowing = distributedPercentagePreceding; } if (collapsedFollowing) { distributedPercentagePreceding = distributedPercentageFollowing; } if (props.desiredPercentage >= 0) { distributablePercentage = Math.min( distributablePercentage, Math.min( distributedPercentagePreceding, distributedPercentageFollowing ) ); } else { distributablePercentage = Math.max( distributablePercentage, Math.max( distributedPercentagePreceding, distributedPercentageFollowing ) ); } newSizes = [...distributedSizesPreceding, ...distributedSizesFollowing]; } return distributablePercentage; }; var distributePercentage = (props) => { props.desiredPercentage = fixToPrecision(props.desiredPercentage); const resizeDirection = getResizeDirection({ side: props.side, desiredPercentage: props.desiredPercentage }); let distributedPercentage = 0; const distributedSizes = props.initialSizes.slice( props.initialSizesStartIndex, props.initialSizesStartIndex + props.panels.length ); for (let i = props.side === "preceding" ? props.panels.length - 1 : 0; props.side === "preceding" ? i >= 0 : i < props.panels.length; props.side === "preceding" ? i-- : i++) { const panel2 = props.panels[i]; const panelSize2 = props.initialSizes[i + props.initialSizesStartIndex]; const collapsedSize2 = resolveSize( panel2.data.collapsedSize ?? 0, props.rootSize ); if (panel2.data.collapsible && panelSize2 === collapsedSize2) continue; const availablePercentage2 = fixToPrecision( props.desiredPercentage - distributedPercentage ); if (availablePercentage2 === 0) break; switch (resizeDirection) { case "precedingDecreasing": { const minSize2 = resolveSize(panel2.data.minSize, props.rootSize); distributedSizes[i] = Math.max(minSize2, panelSize2 + availablePercentage2); distributedPercentage += distributedSizes[i] - panelSize2; break; } case "followingDecreasing": { const minSize2 = resolveSize(panel2.data.minSize, props.rootSize); distributedSizes[i] = Math.max(minSize2, panelSize2 - availablePercentage2); distributedPercentage -= distributedSizes[i] - panelSize2; break; } case "precedingIncreasing": { const maxSize = resolveSize(panel2.data.maxSize, props.rootSize); distributedSizes[i] = Math.min(maxSize, panelSize2 + availablePercentage2); distributedPercentage += distributedSizes[i] - panelSize2; break; } case "followingIncreasing": { const maxSize = resolveSize(panel2.data.maxSize, props.rootSize); distributedSizes[i] = Math.min(maxSize, panelSize2 - availablePercentage2); distributedPercentage -= distributedSizes[i] - panelSize2; break; } } } distributedPercentage = fixToPrecision(distributedPercentage); if (!props.collapsible || distributedPercentage === props.desiredPercentage) { return [distributedPercentage, distributedSizes, false]; } const panelIndex = props.side === "preceding" ? props.panels.length - 1 : 0; const panel = props.panels[panelIndex]; if (!panel.data.collapsible) { return [distributedPercentage, distributedSizes, false]; } const availablePercentage = fixToPrecision( props.desiredPercentage - distributedPercentage ); let collapsed = false; const panelSize = props.initialSizes[panelIndex + props.initialSizesStartIndex]; const minSize = resolveSize(panel.data.minSize, props.rootSize); const collapsedSize = resolveSize( panel.data.collapsedSize ?? 0, props.rootSize ); const collapseThreshold = Math.min( resolveSize(panel.data.collapseThreshold ?? 0, props.rootSize), minSize - collapsedSize ); const isCollapsed = panelSize === collapsedSize; if (resizeDirection === "precedingDecreasing" && !isCollapsed && Math.abs(availablePercentage) >= collapseThreshold) { distributedPercentage -= distributedSizes[panelIndex] - panelSize; distributedSizes[panelIndex] = collapsedSize; distributedPercentage += distributedSizes[panelIndex] - panelSize; collapsed = true; } else if (resizeDirection === "precedingIncreasing" && isCollapsed && Math.abs(availablePercentage) >= collapseThreshold) { const minSize2 = resolveSize(panel.data.minSize, props.rootSize); distributedSizes[panelIndex] = minSize2; if (Math.abs(availablePercentage) >= minSize2 - collapsedSize) { const maxSize = resolveSize(panel.data.maxSize, props.rootSize); distributedSizes[panelIndex] = Math.min( maxSize, panelSize + availablePercentage ); } else { collapsed = true; } distributedPercentage += distributedSizes[panelIndex] - panelSize; } else if (resizeDirection === "followingDecreasing" && !isCollapsed && Math.abs(availablePercentage) >= collapseThreshold) { distributedPercentage += distributedSizes[panelIndex] - panelSize; distributedSizes[panelIndex] = collapsedSize; distributedPercentage -= distributedSizes[panelIndex] - panelSize; collapsed = true; } else if (resizeDirection === "followingIncreasing" && isCollapsed && Math.abs(availablePercentage) >= collapseThreshold) { const minSize2 = resolveSize(panel.data.minSize, props.rootSize); distributedSizes[panelIndex] = minSize2; if (Math.abs(availablePercentage) >= minSize2 - collapsedSize) { const maxSize = resolveSize(panel.data.maxSize, props.rootSize); distributedSizes[panelIndex] = Math.min( maxSize, panelSize - availablePercentage ); } else { collapsed = true; } distributedPercentage -= distributedSizes[panelIndex] - panelSize; } return [distributedPercentage, distributedSizes, collapsed]; }; var getResizeDirection = (props) => { switch (props.side) { case "preceding": return props.desiredPercentage >= 0 ? "precedingIncreasing" : "precedingDecreasing"; case "following": return props.desiredPercentage >= 0 ? "followingDecreasing" : "followingIncreasing"; } }; var resize = (props) => { let newSizes = props.initialSizes; for (const resizeAction of props.resizeActions) { const [, distributedSizesPreceding] = distributePercentage({ desiredPercentage: resizeAction.deltaPercentage, side: "preceding", panels: resizeAction.precedingPanels, initialSizes: newSizes, initialSizesStartIndex: 0, collapsible: props.collapsible, rootSize: props.resizableData.rootSize }); const [, distributedSizesFollowing] = distributePercentage({ desiredPercentage: resizeAction.deltaPercentage, side: "following", panels: resizeAction.followingPanels, initialSizes: newSizes, initialSizesStartIndex: resizeAction.precedingPanels.length, collapsible: props.collapsible, rootSize: props.resizableData.rootSize }); newSizes = [...distributedSizesPreceding, ...distributedSizesFollowing]; } newSizes = newSizes.map(fixToPrecision); const totalSize = newSizes.reduce((totalSize2, size) => totalSize2 + size, 0); if (totalSize !== 1) { const offset = totalSize - 1; const offsetPerPanel = offset / newSizes.length; newSizes = newSizes.map((size) => size - offsetPerPanel); } props.resizableData.setSizes(newSizes.map(fixToPrecision)); }; var resizePanel = (props) => { let [precedingPanels, followingPanels] = splitPanels({ panels: props.panels, focusedElement: props.panel.data.element }); const panelIndex = props.panels.indexOf(props.panel); if (panelIndex === 0) { props.strategy = "following"; } else if (panelIndex === props.panels.length - 1) { props.strategy = "preceding"; } if (props.strategy === "both") { const precedingPanelsIncluding = [...precedingPanels, props.panel]; const followingPanelsIncluding = [props.panel, ...followingPanels]; const distributablePercentage = getDistributablePercentage({ desiredPercentage: props.deltaPercentage / 2, initialSizes: props.initialSizes, collapsible: true, resizeActions: [ { precedingPanels: precedingPanelsIncluding, followingPanels }, { precedingPanels, followingPanels: followingPanelsIncluding, negate: true } ], resizableData: { rootSize: props.resizableData.rootSize } }); resize({ initialSizes: props.initialSizes, collapsible: true, resizeActions: [ { precedingPanels: precedingPanelsIncluding, followingPanels, deltaPercentage: distributablePercentage }, { precedingPanels, followingPanels: followingPanelsIncluding, deltaPercentage: -distributablePercentage } ], resizableData: props.resizableData }); } else { precedingPanels = props.strategy === "preceding" ? precedingPanels : [...precedingPanels, props.panel]; followingPanels = props.strategy === "following" ? followingPanels : [props.panel, ...followingPanels]; if (props.strategy === "preceding") { props.deltaPercentage = -props.deltaPercentage; } const distributablePercentage = getDistributablePercentage({ desiredPercentage: props.deltaPercentage, initialSizes: props.initialSizes, collapsible: props.collapsible, resizeActions: [ { precedingPanels, followingPanels } ], resizableData: { rootSize: props.resizableData.rootSize } }); resize({ initialSizes: props.initialSizes, collapsible: true, resizeActions: [ { precedingPanels, followingPanels, deltaPercentage: distributablePercentage } ], resizableData: props.resizableData }); } }; var deltaResize = (props) => { if (props.altKey && props.panels.length > 2) { let panelIndex = props.panels.filter( (panel2) => !!(props.handle.compareDocumentPosition(panel2.data.element) & Node.DOCUMENT_POSITION_PRECEDING) ).length - 1; const isPrecedingHandle = panelIndex === 0; if (isPrecedingHandle) { panelIndex++; props.deltaPercentage = -props.deltaPercentage; } const panel = props.panels[panelIndex]; const panelSize = props.initialSizes[panelIndex]; const minDelta = resolveSize(panel.data.minSize, props.resizableData.rootSize) - panelSize; const maxDelta = resolveSize(panel.data.maxSize, props.resizableData.rootSize) - panelSize; const cappedDeltaPercentage = Math.max(minDelta, Math.min(props.deltaPercentage * 2, maxDelta)) / 2; const [precedingPanels, followingPanels] = splitPanels({ panels: props.panels, focusedElement: panel.data.element }); const precedingPanelsIncluding = [...precedingPanels, panel]; const followingPanelsIncluding = [panel, ...followingPanels]; const distributablePercentage = getDistributablePercentage({ desiredPercentage: cappedDeltaPercentage, initialSizes: props.initialSizes, collapsible: false, resizeActions: [ { precedingPanels: precedingPanelsIncluding, followingPanels }, { precedingPanels, followingPanels: followingPanelsIncluding, negate: true } ], resizableData: { rootSize: props.resizableData.rootSize } }); if (props.resizableData.handleCursorStyle === true) { handleResizeConstraints({ orientation: props.resizableData.orientation, desiredPercentage: props.deltaPercentage, distributablePercentage, revertConstraints: isPrecedingHandle }); } resize({ initialSizes: props.initialSizes, collapsible: false, resizeActions: [ { precedingPanels: precedingPanelsIncluding, followingPanels, deltaPercentage: distributablePercentage }, { precedingPanels, followingPanels: followingPanelsIncluding, deltaPercentage: -distributablePercentage } ], resizableData: props.resizableData }); } else { const [precedingPanels, followingPanels] = splitPanels({ panels: props.panels, focusedElement: props.handle }); const distributablePercentage = getDistributablePercentage({ desiredPercentage: props.deltaPercentage, initialSizes: props.initialSizes, collapsible: true, resizeActions: [ { precedingPanels, followingPanels } ], resizableData: { rootSize: props.resizableData.rootSize } }); resize({ initialSizes: props.initialSizes, collapsible: true, resizeActions: [ { precedingPanels, followingPanels, deltaPercentage: distributablePercentage } ], resizableData: props.resizableData }); if (props.resizableData.handleCursorStyle) { const fixedDesiredPercentage = fixToPrecision(props.deltaPercentage); const fixedDistributablePercentage = fixToPrecision( distributablePercentage ); let betweenCollapse = false; const precedingPanel = precedingPanels[precedingPanels.length - 1]; if (precedingPanel.data.collapsible) { const precedingCollapsedSize = resolveSize( precedingPanel.data.collapsedSize ?? 0, props.resizableData.rootSize ); if (precedingPanel.size() === precedingCollapsedSize && fixedDesiredPercentage > fixedDistributablePercentage || precedingPanel.size() !== precedingCollapsedSize && fixedDesiredPercentage < fixedDistributablePercentage) { betweenCollapse = true; } } const followingPanel = followingPanels[0]; if (followingPanel.data.collapsible) { const followingCollapsedSize = resolveSize( followingPanel.data.collapsedSize ?? 0, props.resizableData.rootSize ); if (followingPanel.size() === followingCollapsedSize && fixedDesiredPercentage < fixedDistributablePercentage || followingPanel.size() !== followingCollapsedSize && fixedDesiredPercentage > fixedDistributablePercentage) { betweenCollapse = true; } } handleResizeConstraints({ orientation: props.resizableData.orientation, desiredPercentage: props.deltaPercentage, distributablePercentage, betweenCollapse }); } } }; // src/Root.tsx import { Dynamic as Dynamic3 } from "@corvu/utils/dynamic"; import { isFunction as isFunction2 } from "@corvu/utils"; import createControllableSignal from "@corvu/utils/create/controllableSignal"; import createOnce2 from "@corvu/utils/create/once"; import createSize from "@corvu/utils/create/size"; import { mergeRefs as mergeRefs3 } from "@corvu/utils/reactivity"; var ResizableRoot = (props) => { const defaultedProps = mergeProps3( { orientation: "horizontal", initialSizes: [], keyboardDelta: 0.1, handleCursorStyle: true }, props ); const [localProps, otherProps] = splitProps3(defaultedProps, [ "orientation", "sizes", "onSizesChange", "initialSizes", "keyboardDelta", "handleCursorStyle", "contextId", "ref", "style", "children" ]); const [sizes, setSizes] = createControllableSignal({ value: () => localProps.sizes, initialValue: [], onChange: localProps.onSizesChange }); const [ref, setRef] = createSignal3(null); const rootSize = createSize({ element: ref, dimension: () => localProps.orientation === "horizontal" ? "width" : "height" }); const [panels, setPanels] = createSignal3([]); const sizesToIds = []; const registerPanel = (panelData) => { const _panels = panels(); const panelIndex = _panels.filter( (panel2) => !!(panelData.element.compareDocumentPosition(panel2.data.element) & Node.DOCUMENT_POSITION_PRECEDING) ).length; const idExists = sizesToIds[panelIndex] === void 0 || sizesToIds[panelIndex] === panelData.id; const sizeExists = sizes()[panelIndex] !== void 0; let panelSize = null; if (panelData.initialSize !== null) { panelSize = resolveSize(panelData.initialSize, rootSize()); } else if (localProps.initialSizes[panelIndex] !== void 0 && idExists) { panelSize = resolveSize(localProps.initialSizes[panelIndex], rootSize()); } panelSize = panelSize ?? 0.5; setSizes((sizes2) => { let newSizes = [...sizes2]; const previousTotalSize = newSizes.reduce( (totalSize, size) => totalSize + size, 0 ); if ((idExists && !sizeExists || !idExists) && previousTotalSize === 1) { const offsetPerPanel = panelSize / newSizes.length; newSizes = newSizes.map((size) => size - offsetPerPanel); } if (idExists) { if (!sizeExists) { newSizes[panelIndex] = panelSize; } sizesToIds[panelIndex] = panelData.id; } else { newSizes.splice(panelIndex, 0, panelSize); sizesToIds.splice(panelIndex, 0, panelData.id); } return newSizes; }); const panelSizeMemo = createMemo3(() => { const index = sizesToIds.indexOf(panelData.id); return sizes()[index]; }); createEffect3(() => panelData.onResize?.(panelSizeMemo())); const panel = { data: panelData, size: panelSizeMemo, resize: (size, strategy) => resize2(sizesToIds.indexOf(panelData.id), size, strategy), collapse: (strategy) => collapse(sizesToIds.indexOf(panelData.id), strategy), expand: (strategy) => expand(sizesToIds.indexOf(panelData.id), strategy) }; setPanels((panels2) => { const newPanels = [...panels2]; newPanels.push(panel); newPanels.sort( (a, b) => sortByDocumentPosition(a.data.element, b.data.element) ); return newPanels; }); return panel; }; const unregisterPanel = (id) => { setPanels((panels2) => panels2.filter((panel) => panel.data.id !== id)); const panelSizeIndex = sizesToIds.indexOf(id); sizesToIds.splice(panelSizeIndex, 1); setSizes((sizes2) => { let newSizes = [...sizes2]; newSizes.splice(panelSizeIndex, 1); const totalSize = newSizes.reduce( (totalSize2, size) => totalSize2 + size, 0 ); const offset = totalSize - 1; const offsetPerPanel = offset / newSizes.length; newSizes = newSizes.map((size) => size + offsetPerPanel); return newSizes; }); }; createEffect3(() => { if (localProps.onSizesChange !== void 0) { localProps.onSizesChange(sizes()); } }); const resize2 = (panelIndex, size, strategy) => { untrack2(() => { const panel = panels()[panelIndex]; if (!panel) return; const minSize = resolveSize(panel.data.minSize, rootSize()); const maxSize = resolveSize(panel.data.maxSize, rootSize()); const newSize = resolveSize(size, rootSize()); const allowedSize = Math.max(minSize, Math.min(newSize, maxSize)); const deltaPercentage = allowedSize - sizes()[panelIndex]; resizePanel({ deltaPercentage, strategy: strategy ?? "both", panel, panels: panels(), initialSizes: panels().map((panel2) => panel2.size()), collapsible: false, resizableData: { rootSize: rootSize(), orientation: localProps.orientation, setSizes } }); }); }; const collapse = (panelIndex, strategy) => { untrack2(() => { const panel = panels()[panelIndex]; if (!panel) return; const panelSize = sizes()[panelIndex]; const collapsedSize = resolveSize( panel.data.collapsedSize ?? 0, rootSize() ); if (!panel.data.collapsible || panelSize === collapsedSize) return; const deltaPercentage = collapsedSize - panelSize; resizePanel({ deltaPercentage, strategy: strategy ?? "both", panel, panels: panels(), initialSizes: panels().map((panel2) => panel2.size()), collapsible: true, resizableData: { rootSize: rootSize(), orientation: localProps.orientation, setSizes } }); }); }; const expand = (panelIndex, strategy) => { untrack2(() => { const panel = panels()[panelIndex]; if (!panel) return; const panelSize = sizes()[panelIndex]; const collapsedSize = resolveSize( panel.data.collapsedSize ?? 0, rootSize() ); if (!panel.data.collapsible || panelSize !== collapsedSize) return; const minSize = resolveSize(panel.data.minSize, rootSize()); const deltaPercentage = minSize - panelSize; resizePanel({ deltaPercentage, strategy: strategy ?? "both", panel, panels: panels(), i