blob: f7436fd6d313b91b49c3760330b613ae965c2c9e [file] [log] [blame] [edit]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {classNames} from '../base/classnames';
type SplitSizePixels = {
readonly pixels: number;
};
type SplitSizePercent = {
readonly percent: number;
};
// Split configuration - either percentage or fixed size in pixels.
type SplitSize = SplitSizePixels | SplitSizePercent;
export interface SplitPanelAttrs {
// Layout direction. Default is 'horizontal'.
readonly direction?: 'horizontal' | 'vertical';
// Controls which panel has the controlled size. Default is 'first'.
readonly controlledPanel?: 'first' | 'second';
// Initial split configuration for uncontrolled mode. Only read once in
// oninit. Ignored if `split` is provided.
readonly initialSplit?: SplitSize;
// Controlled split configuration. When provided, the component will use this
// value directly and the parent must update it via onResize to see changes.
readonly split?: SplitSize;
// Minimum size in pixels for each panel
readonly minSize?: number;
// Additional CSS class for the root element.
readonly className?: string;
// Content for the first panel
readonly firstPanel: m.Children;
// Content for the second panel
readonly secondPanel: m.Children;
// Callback invoked when the user resizes a panel in both controlled and
// uncontrolled modes.
readonly onResize?: (size: number) => void;
}
// Factory function to create SplitPanel instances with their own state
export function SplitPanel(
vnode: m.Vnode<SplitPanelAttrs>,
): m.Component<SplitPanelAttrs> {
// Internal state for uncontrolled mode
let internalPercent = 50;
let internalFixedPx = 150;
let isResizing = false;
const initial = vnode.attrs.initialSplit ?? {percent: 50};
if ('pixels' in initial) {
internalFixedPx = initial.pixels;
} else if ('percent' in initial) {
internalPercent = initial.percent;
}
return {
view(vnode) {
const {
direction = 'horizontal',
minSize = 50,
split,
initialSplit,
firstPanel,
secondPanel,
controlledPanel: panel,
} = vnode.attrs;
// Determine if we're in controlled or uncontrolled mode
const isControlled = split !== undefined;
const effectiveSplit = split ?? initialSplit ?? {percent: 50};
const isPixelMode = 'pixels' in effectiveSplit;
const controlledPanel = panel ?? 'first';
const containerClasses = classNames(
'pf-split-panel',
`pf-split-${direction}`,
vnode.attrs.className,
);
const handleSize = 4;
// Get current size - from controlled prop or internal state
let currentPercent: number;
let currentPixels: number;
if (isControlled && isPixelMode) {
currentPixels = effectiveSplit.pixels;
currentPercent = 50; // unused in pixel mode
} else if (isControlled && 'percent' in effectiveSplit) {
currentPercent = effectiveSplit.percent;
currentPixels = 150; // unused in percent mode
} else {
currentPercent = internalPercent;
currentPixels = internalFixedPx;
}
let firstStyle: Record<string, string>;
let secondStyle: Record<string, string>;
// Use CSS min/max width/height to enforce size constraints
const minProp = direction === 'horizontal' ? 'minWidth' : 'minHeight';
const maxProp = direction === 'horizontal' ? 'maxWidth' : 'maxHeight';
const maxSize = `calc(100% - ${minSize}px - ${handleSize}px)`;
if (isPixelMode) {
// Pixel mode - one panel fixed, one flexible
if (controlledPanel === 'first') {
firstStyle = {
flex: `0 0 ${currentPixels}px`,
[minProp]: `${minSize}px`,
[maxProp]: maxSize,
};
secondStyle = {flex: '1 1 0', [minProp]: `${minSize}px`};
} else {
firstStyle = {flex: '1 1 0', [minProp]: `${minSize}px`};
secondStyle = {
flex: `0 0 ${currentPixels}px`,
[minProp]: `${minSize}px`,
[maxProp]: maxSize,
};
}
} else {
// Percentage mode
const firstPercent =
controlledPanel === 'first' ? currentPercent : 100 - currentPercent;
firstStyle = {
flex: `0 0 calc(${firstPercent}% - ${handleSize / 2}px)`,
[minProp]: `${minSize}px`,
[maxProp]: maxSize,
};
secondStyle = {
flex: `0 0 calc(${100 - firstPercent}% - ${handleSize / 2}px)`,
[minProp]: `${minSize}px`,
[maxProp]: maxSize,
};
}
const onPointerDown = (e: PointerEvent) => {
e.preventDefault();
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
isResizing = true;
document.body.style.cursor =
direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
handle.classList.add('pf-split-handle--active');
};
const onPointerMove = (e: PointerEvent) => {
if (!isResizing) return;
const handle = e.currentTarget as HTMLElement;
const container = handle.parentElement!;
const rect = container.getBoundingClientRect();
const containerSize =
direction === 'horizontal' ? rect.width : rect.height;
// Calculate position from start of container
let pos: number;
if (direction === 'horizontal') {
pos = e.clientX - rect.left;
} else {
pos = e.clientY - rect.top;
}
if (isPixelMode) {
// Pixel mode
let newSize: number;
if (controlledPanel === 'first') {
newSize = pos;
} else {
newSize = containerSize - pos;
}
// Clamp to min/max
newSize = Math.max(
minSize,
Math.min(containerSize - minSize - handleSize, newSize),
);
// Update internal state only in uncontrolled mode
if (!isControlled) {
internalFixedPx = newSize;
}
if (vnode.attrs.onResize) {
vnode.attrs.onResize(newSize);
}
} else {
// Percentage mode
let newPercent = (pos / containerSize) * 100;
// If controlling second panel, invert the percentage
if (controlledPanel === 'second') {
newPercent = 100 - newPercent;
}
const minPercent = (minSize / containerSize) * 100;
const maxPercent = 100 - minPercent;
newPercent = Math.max(minPercent, Math.min(maxPercent, newPercent));
// Update internal state only in uncontrolled mode
if (!isControlled) {
internalPercent = newPercent;
}
if (vnode.attrs.onResize) {
vnode.attrs.onResize(newPercent);
}
}
};
const onPointerUp = (e: PointerEvent) => {
if (isResizing) {
const handle = e.currentTarget as HTMLElement;
handle.releasePointerCapture(e.pointerId);
isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
handle.classList.remove('pf-split-handle--active');
}
};
return m('div', {class: containerClasses}, [
m('.pf-split-panel__first', {style: firstStyle}, firstPanel),
m('.pf-split-panel__handle', {
onpointerdown: onPointerDown,
onpointermove: onPointerMove,
onpointerup: onPointerUp,
onpointercancel: onPointerUp,
}),
m('.pf-split-panel__second', {style: secondStyle}, secondPanel),
]);
},
};
}