UNPKG

@zag-js/splitter

Version:

Core logic for the splitter widget implemented as a state machine

646 lines (644 loc) • 23.2 kB
import "./chunk-QZ7TP4HQ.mjs"; // src/splitter.machine.ts import { createMachine } from "@zag-js/core"; import { observeChildren, resizeObserverBorderBox, trackPointerMove } from "@zag-js/dom-query"; import { ensure, ensureProps, isEqual, next, prev, setRafTimeout } from "@zag-js/utils"; import * as dom from "./splitter.dom.mjs"; import { getAriaValue } from "./utils/aria.mjs"; import { fuzzyNumbersEqual, fuzzySizeEqual } from "./utils/fuzzy.mjs"; import { findPanelDataIndex, getPanelById, getPanelLayout, panelDataHelper, serializePanels, sortPanels } from "./utils/panel.mjs"; import { preserveFixedPanelSizes } from "./utils/preserve-fixed-panel-sizes.mjs"; import { resizeByDelta } from "./utils/resize-by-delta.mjs"; import { getGroupSize, normalizePanels, resolvePanelSizes } from "./utils/size.mjs"; import { validateSizes } from "./utils/validate-sizes.mjs"; var machine = createMachine({ props({ props }) { ensureProps(props, ["panels"]); return { orientation: "horizontal", defaultSize: [], dir: "ltr", ...props, panels: sortPanels(props.panels) }; }, initialState() { return "idle"; }, context({ prop, bindable, getContext, getRefs }) { return { panels: bindable(() => ({ defaultValue: normalizePanels(prop("panels"), null, prop("orientation")) })), size: bindable(() => ({ defaultValue: [], isEqual(a, b) { return b != null && fuzzySizeEqual(a, b); }, onChange(value) { const ctx = getContext(); const refs = getRefs(); if (refs.get("suppressOnResize")) return; const sizesBeforeCollapse = refs.get("panelSizeBeforeCollapse"); const expandToSizes = Object.fromEntries(sizesBeforeCollapse.entries()); const resizeTriggerId = ctx.get("dragState")?.resizeTriggerId ?? null; const layout = getPanelLayout(prop("panels")); prop("onResize")?.({ size: value, layout, resizeTriggerId, expandToSizes }); } })), dragState: bindable(() => ({ defaultValue: null })), keyboardState: bindable(() => ({ defaultValue: null })) }; }, watch({ track, action, prop }) { track( [ () => serializePanels(prop("panels")), () => JSON.stringify(prop("size") ?? []), () => JSON.stringify(prop("defaultSize") ?? []) ], () => { action(["syncSize"]); } ); }, refs() { return { panelSizeBeforeCollapse: /* @__PURE__ */ new Map(), prevDelta: 0, panelIdToLastNotifiedSizeMap: /* @__PURE__ */ new Map(), initialSize: null, prevInitialLayout: null, prevGroupSize: null, lastRequestedSize: null, suppressOnResize: false }; }, computed: { horizontal({ prop }) { return prop("orientation") === "horizontal"; } }, on: { "SIZE.SET": { actions: ["setSize"] }, "SIZE.RESET": { actions: ["resetSize"] }, "PANEL.COLLAPSE": { actions: ["collapsePanel"] }, "PANEL.EXPAND": { actions: ["expandPanel"] }, "PANEL.RESIZE": { actions: ["resizePanel"] }, "ROOT.RESIZE": { actions: ["syncSize"] } }, entry: ["syncSize"], exit: ["clearGlobalCursor"], effects: ["trackResizeHandles", "trackRootResize"], states: { idle: { entry: ["clearDraggingState", "clearKeyboardState"], on: { POINTER_OVER: { target: "hover:temp", actions: ["setKeyboardState"] }, FOCUS: { target: "focused", actions: ["setKeyboardState"] }, POINTER_DOWN: { target: "dragging", actions: ["setDraggingState"] } } }, "hover:temp": { effects: ["waitForHoverDelay"], on: { HOVER_DELAY: { target: "hover" }, POINTER_DOWN: { target: "dragging", actions: ["setDraggingState"] }, POINTER_LEAVE: { target: "idle" } } }, hover: { tags: ["focus"], on: { POINTER_DOWN: { target: "dragging", actions: ["setDraggingState"] }, POINTER_LEAVE: { target: "idle" } } }, focused: { tags: ["focus"], on: { BLUR: { target: "idle" }, ENTER: { actions: ["collapseOrExpandPanel"] }, POINTER_DOWN: { target: "dragging", actions: ["setDraggingState"] }, KEYBOARD_MOVE: { actions: ["invokeOnResizeStart", "setKeyboardValue", "invokeOnResizeEnd"] }, "FOCUS.CYCLE": { actions: ["focusNextResizeTrigger"] } } }, dragging: { tags: ["focus"], effects: ["trackPointerMove"], entry: ["invokeOnResizeStart"], on: { POINTER_MOVE: { actions: ["setPointerValue", "setGlobalCursor"] }, POINTER_UP: [ { guard: "isResizeTriggerFocused", target: "focused", actions: ["invokeOnResizeEnd", "setKeyboardState", "clearDraggingState", "clearGlobalCursor"] }, { target: "idle", actions: ["invokeOnResizeEnd", "clearGlobalCursor"] } ] } } }, implementations: { guards: { isResizeTriggerFocused({ context, scope }) { const dragState = context.get("dragState"); return scope.isActiveElement(dom.getResizeTriggerEl(scope, dragState?.resizeTriggerId)); } }, effects: { trackResizeHandles: ({ prop, scope, send }) => { const registry = prop("registry"); if (!registry) return; let cleanups = []; const exec = () => { cleanups.forEach((fn) => fn()); cleanups = dom.getResizeTriggerEls(scope).map((resizeTriggerEl) => { const id = resizeTriggerEl.dataset.id; if (!id) return; return registry.register({ id: dom.getResizeTriggerId(scope, id), element: resizeTriggerEl, orientation: prop("orientation"), onActivate(point) { send({ type: "POINTER_DOWN", id, point }); }, onDeactivate() { send({ type: "POINTER_UP" }); } }); }).filter(Boolean); }; exec(); const observeCleanup = observeChildren(dom.getRootEl(scope), { callback: exec }); return () => { cleanups.forEach((fn) => fn()); observeCleanup?.(); }; }, trackRootResize: ({ scope, send }) => { const rootEl = dom.getRootEl(scope); if (!rootEl) return; return resizeObserverBorderBox.observe(rootEl, () => { send({ type: "ROOT.RESIZE" }); }); }, waitForHoverDelay: ({ send }) => { return setRafTimeout(() => { send({ type: "HOVER_DELAY" }); }, 250); }, trackPointerMove: ({ scope, send }) => { const doc = scope.getDoc(); return trackPointerMove(doc, { onPointerMove(info) { send({ type: "POINTER_MOVE", point: info.point }); }, onPointerUp() { send({ type: "POINTER_UP" }); } }); } }, actions: { setSize(params) { const { context, event, prop, scope } = params; const unsafeSize = event.size; const prevSize = context.get("size"); const panels = context.get("panels"); const safeSize = validateSizes({ size: resolvePanelSizes({ sizes: unsafeSize, panels: prop("panels"), rootEl: dom.getRootEl(scope), orientation: prop("orientation") }), panels }); if (!isEqual(prevSize, safeSize)) { setSize(params, safeSize); } }, resetSize(params) { const { refs, context, prop, scope } = params; const initialSize = refs.get("initialSize"); const nextSize = initialSize ?? validateSizes({ size: resolvePanelSizes({ sizes: prop("size") ?? prop("defaultSize"), panels: prop("panels"), rootEl: dom.getRootEl(scope), orientation: prop("orientation") }), panels: context.get("panels") }); setSize(params, nextSize); }, syncSize(params) { const { context, scope, prop, refs } = params; const rootEl = dom.getRootEl(scope); if (!rootEl) return; const orientation = prop("orientation"); const nextGroupSize = getGroupSize(rootEl, orientation); if (nextGroupSize <= 0) return; const panels = normalizePanels(prop("panels"), rootEl, prop("orientation")); context.set("panels", panels); const sizeSpec = prop("size") ?? prop("defaultSize"); const initialLayout = `${getPanelLayout(prop("panels"))}:${JSON.stringify(prop("size") ?? [])}:${JSON.stringify(prop("defaultSize") ?? [])}`; const prevGroupSize = refs.get("prevGroupSize"); const currentSize = context.get("size"); const nextResolvedSize = resolvePanelSizes({ sizes: sizeSpec, panels: prop("panels"), rootEl, orientation }); const canPreserveLayout = prevGroupSize != null && prevGroupSize !== nextGroupSize && currentSize.length === panels.length; const nextSize = canPreserveLayout ? preserveFixedPanelSizes({ panels, prevLayout: currentSize, prevGroupSize, nextGroupSize }) : nextResolvedSize; const safeSize = validateSizes({ size: nextSize, panels }); if (refs.get("prevInitialLayout") !== initialLayout) { refs.set("initialSize", safeSize); refs.set("prevInitialLayout", initialLayout); } const prevSize = context.get("size"); if (!isEqual(prevSize, safeSize)) { refs.set("suppressOnResize", prop("size") != null || prevSize.length === 0); context.set("size", safeSize); refs.set("suppressOnResize", false); } refs.set("prevGroupSize", nextGroupSize); }, setDraggingState({ context, event, prop, scope }) { const orientation = prop("orientation"); const size = context.get("size"); const resizeTriggerId = event.id; const resolvedResizeTriggerId = dom.resolveResizeTriggerId(scope, resizeTriggerId); if (!resolvedResizeTriggerId) return; const panelGroupEl = dom.getRootEl(scope); if (!panelGroupEl) return; const handleElement = dom.getResizeTriggerEl(scope, resizeTriggerId); ensure(handleElement, () => `Drag handle element not found for id "${resizeTriggerId}"`); const initialCursorPosition = orientation === "horizontal" ? event.point.x : event.point.y; context.set("dragState", { resizeTriggerId: event.id, resolvedResizeTriggerId, resizeTriggerRect: handleElement.getBoundingClientRect(), initialCursorPosition, initialSize: size }); }, clearDraggingState({ context }) { context.set("dragState", null); }, setKeyboardState({ context, event, scope }) { const id = event.id ?? context.get("dragState")?.resizeTriggerId; if (id == null) return; context.set("keyboardState", { resizeTriggerId: id, resolvedResizeTriggerId: dom.resolveResizeTriggerId(scope, id) }); }, clearKeyboardState({ context }) { context.set("keyboardState", null); }, collapsePanel(params) { const { context, event, refs } = params; const prevSize = context.get("size"); const panels = context.get("panels"); const panel = panels.find((panel2) => panel2.id === event.id); ensure(panel, () => `Panel data not found for id "${event.id}"`); if (panel.collapsible) { const { collapsedSize = 0, panelSize, pivotIndices } = panelDataHelper(panels, panel, prevSize); ensure(panelSize != null, () => `Panel size not found for panel "${panel.id}"`); if (!fuzzyNumbersEqual(panelSize, collapsedSize)) { refs.get("panelSizeBeforeCollapse").set(panel.id, panelSize); const isLastPanel = findPanelDataIndex(panels, panel) === panels.length - 1; const delta = isLastPanel ? panelSize - collapsedSize : collapsedSize - panelSize; const nextSize = resizeByDelta({ delta, initialSize: prevSize, panels, pivotIndices, prevSize, trigger: "imperative-api" }); if (!isEqual(prevSize, nextSize)) { setSize(params, nextSize); } } } }, expandPanel(params) { const { context, event, refs } = params; const panels = context.get("panels"); const prevSize = context.get("size"); const panel = panels.find((panel2) => panel2.id === event.id); ensure(panel, () => `Panel data not found for id "${event.id}"`); if (panel.collapsible) { const { collapsedSize = 0, panelSize = 0, minSize: minSizeFromProps = 0, pivotIndices } = panelDataHelper(panels, panel, prevSize); const minSize = event.minSize ?? minSizeFromProps; if (fuzzyNumbersEqual(panelSize, collapsedSize)) { const prevPanelSize = refs.get("panelSizeBeforeCollapse").get(panel.id); const baseSize = prevPanelSize != null && prevPanelSize >= minSize ? prevPanelSize : minSize; const isLastPanel = findPanelDataIndex(panels, panel) === panels.length - 1; const delta = isLastPanel ? panelSize - baseSize : baseSize - panelSize; const nextSize = resizeByDelta({ delta, initialSize: prevSize, panels, pivotIndices, prevSize, trigger: "imperative-api" }); if (!isEqual(prevSize, nextSize)) { setSize(params, nextSize); } } } }, resizePanel(params) { const { context, event } = params; const prevSize = context.get("size"); const panels = context.get("panels"); const panel = getPanelById(panels, event.id); const unsafePanelSize = event.size; const { panelSize, pivotIndices } = panelDataHelper(panels, panel, prevSize); ensure(panelSize != null, () => `Panel size not found for panel "${panel.id}"`); const isLastPanel = findPanelDataIndex(panels, panel) === panels.length - 1; const delta = isLastPanel ? panelSize - unsafePanelSize : unsafePanelSize - panelSize; const nextSize = resizeByDelta({ delta, initialSize: prevSize, panels, pivotIndices, prevSize, trigger: "imperative-api" }); if (!isEqual(prevSize, nextSize)) { setSize(params, nextSize); } }, setPointerValue(params) { const { context, event, prop, scope } = params; const dragState = context.get("dragState"); if (!dragState) return; const { resolvedResizeTriggerId, initialSize, initialCursorPosition } = dragState; const panels = context.get("panels"); const panelGroupElement = dom.getRootEl(scope); ensure(panelGroupElement, () => `Panel group element not found`); const pivotIndices = resolvedResizeTriggerId.split(":").map((id) => panels.findIndex((panel) => panel.id === id)); const horizontal = prop("orientation") === "horizontal"; const cursorPosition = horizontal ? event.point.x : event.point.y; const groupRect = panelGroupElement.getBoundingClientRect(); const groupSizeInPixels = horizontal ? groupRect.width : groupRect.height; const offsetPixels = cursorPosition - initialCursorPosition; const offsetPercentage = offsetPixels / groupSizeInPixels * 100; const prevSize = context.get("size"); const nextSize = resizeByDelta({ delta: offsetPercentage, initialSize: initialSize ?? prevSize, panels, pivotIndices, prevSize, trigger: "mouse-or-touch" }); if (!isEqual(prevSize, nextSize)) { setSize(params, nextSize); } }, setKeyboardValue(params) { const { context, event } = params; const panelDataArray = context.get("panels"); const resizeTriggerId = dom.resolveResizeTriggerId(params.scope, event.id); if (!resizeTriggerId) return; const delta = event.delta; const pivotIndices = resizeTriggerId.split(":").map((id) => panelDataArray.findIndex((panelData) => panelData.id === id)); const prevSize = context.get("size"); const nextSize = resizeByDelta({ delta, initialSize: prevSize, panels: panelDataArray, pivotIndices, prevSize, trigger: "keyboard" }); if (!isEqual(prevSize, nextSize)) { setSize(params, nextSize); } }, invokeOnResizeEnd({ context, prop, refs }) { queueMicrotask(() => { const dragState = context.get("dragState"); prop("onResizeEnd")?.({ size: refs.get("lastRequestedSize") ?? context.get("size"), resizeTriggerId: dragState?.resizeTriggerId ?? null }); }); }, invokeOnResizeStart({ prop }) { queueMicrotask(() => { prop("onResizeStart")?.(); }); }, collapseOrExpandPanel(params) { const { context, refs } = params; const panelDataArray = context.get("panels"); const sizes = context.get("size"); const resizeTriggerId = context.get("keyboardState")?.resolvedResizeTriggerId; const [idBefore, idAfter] = resizeTriggerId?.split(":") ?? []; const index = panelDataArray.findIndex((panelData2) => panelData2.id === idBefore); if (index === -1) return; const panelData = panelDataArray[index]; ensure(panelData, () => `No panel data found for index ${index}`); const size = sizes[index]; const { collapsedSize = 0, collapsible, minSize = 0 } = panelData; if (size != null && collapsible) { const pivotIndices = [idBefore, idAfter].map( (id) => panelDataArray.findIndex((panelData2) => panelData2.id === id) ); const nextSize = resizeByDelta({ delta: fuzzyNumbersEqual(size, collapsedSize) ? minSize - collapsedSize : collapsedSize - size, initialSize: refs.get("initialSize") ?? sizes, panels: panelDataArray, pivotIndices, prevSize: sizes, trigger: "keyboard" }); if (!isEqual(sizes, nextSize)) { setSize(params, nextSize); } } }, setGlobalCursor(params) { const { context, scope, prop } = params; const registry = prop("registry"); if (registry) return; const dragState = context.get("dragState"); if (!dragState) return; const panels = context.get("panels"); const horizontal = prop("orientation") === "horizontal"; const [idBefore] = dragState.resolvedResizeTriggerId.split(":"); const indexBefore = panels.findIndex((panel2) => panel2.id === idBefore); const panel = panels[indexBefore]; const size = context.get("size"); const aria = getAriaValue(size, panels, dragState.resolvedResizeTriggerId); const isAtMin = fuzzyNumbersEqual(aria.valueNow, aria.valueMin) || fuzzyNumbersEqual(aria.valueNow, panel.collapsedSize); const isAtMax = fuzzyNumbersEqual(aria.valueNow, aria.valueMax); const cursorState = { isAtMin, isAtMax }; dom.setupGlobalCursor(scope, cursorState, horizontal, prop("nonce")); }, clearGlobalCursor({ scope }) { dom.removeGlobalCursor(scope); }, focusNextResizeTrigger({ event, scope }) { const resizeTriggers = dom.getResizeTriggerEls(scope); const index = resizeTriggers.findIndex((el) => el.dataset.id === event.id); const handleEl = event.shiftKey ? prev(resizeTriggers, index) : next(resizeTriggers, index); handleEl?.focus(); } } } }); function setSize(params, sizes) { const { refs, prop, context } = params; const panelsArray = context.get("panels"); const onCollapse = prop("onCollapse"); const onExpand = prop("onExpand"); const onResize = prop("onResize"); const onResizeStart = prop("onResizeStart"); const onResizeEnd = prop("onResizeEnd"); const panelIdToLastNotifiedSizeMap = refs.get("panelIdToLastNotifiedSizeMap"); const dragState = context.get("dragState"); const keyboardState = context.get("keyboardState"); const isProgrammatic = dragState === null && keyboardState === null; refs.set("lastRequestedSize", sizes); if (isProgrammatic && onResizeStart) { queueMicrotask(() => { onResizeStart(); }); } if (prop("size") == null) { context.set("size", sizes); } else if (onResize) { const sizesBeforeCollapse = refs.get("panelSizeBeforeCollapse"); const expandToSizes = Object.fromEntries(sizesBeforeCollapse.entries()); const resizeTriggerId = dragState?.resizeTriggerId ?? null; const layout = getPanelLayout(prop("panels")); onResize({ size: sizes, layout, resizeTriggerId, expandToSizes }); } sizes.forEach((size, index) => { const panelData = panelsArray[index]; ensure(panelData, () => `Panel data not found for index ${index}`); const { collapsedSize = 0, collapsible, id: panelId } = panelData; const lastNotifiedSize = panelIdToLastNotifiedSizeMap.get(panelId); if (lastNotifiedSize == null || size !== lastNotifiedSize) { panelIdToLastNotifiedSizeMap.set(panelId, size); if (collapsible && lastNotifiedSize != null && (onCollapse || onExpand)) { if (fuzzyNumbersEqual(lastNotifiedSize, collapsedSize) && !fuzzyNumbersEqual(size, collapsedSize)) { onExpand?.({ panelId, size }); } if (!fuzzyNumbersEqual(lastNotifiedSize, collapsedSize) && fuzzyNumbersEqual(size, collapsedSize)) { onCollapse?.({ panelId, size }); } } } }); if (isProgrammatic && onResizeEnd) { queueMicrotask(() => { onResizeEnd({ size: sizes, resizeTriggerId: null // Programmatic changes don't have a resize trigger }); }); } } export { machine };