LCOV - code coverage report
Current view: top level - dashql-app/src/ui/foundations - vertical_split.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 97.9 % 142 139
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { classNames } from '../../utils/classnames.js';
       4              : import * as styles from './vertical_split.module.css';
       5              : 
       6            1 : const DEFAULT_SEPARATOR_SIZE = 12;
       7            1 : const KEYBOARD_STEP = 0.05;
       8              : const MIN_RATIO = 0;
       9              : const MAX_RATIO = 1;
      10              : 
      11              : export interface VerticalSplitProps {
      12              :     first: React.ReactNode;
      13              :     second: React.ReactNode;
      14              :     className?: string;
      15              :     defaultRatio?: number;
      16              :     minFirstSize?: number;
      17              :     minSecondSize?: number;
      18              :     separatorLabel?: string;
      19              :     secondCollapsed?: boolean;
      20              :     collapsedSecondSize?: number;
      21              :     /// The height of the separator row in pixels
      22              :     separatorSize?: number;
      23              :     separatorClassName?: string;
      24              : }
      25              : 
      26            1 : function clamp(value: number, minimum: number, maximum: number): number {
      27            1 :     return Math.min(maximum, Math.max(minimum, value));
      28            1 : }
      29              : 
      30            1 : export const VerticalSplit: React.FC<VerticalSplitProps> = (props) => {
      31            1 :     const defaultRatio = clamp(props.defaultRatio ?? 0.4, 0, 1);
      32            1 :     const minFirstSize = props.minFirstSize ?? 120;
      33            1 :     const minSecondSize = props.minSecondSize ?? 120;
      34            1 :     const collapsedSecondSize = props.collapsedSecondSize ?? 40;
      35            1 :     const separatorSize = props.separatorSize ?? DEFAULT_SEPARATOR_SIZE;
      36            1 :     const [ratio, setRatio] = React.useState(defaultRatio);
      37            1 :     const [ratioBounds, setRatioBounds] = React.useState<[number, number]>([defaultRatio, defaultRatio]);
      38            1 :     const [isDragging, setIsDragging] = React.useState(false);
      39            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
      40            1 :     const stopDraggingRef = React.useRef<(() => void) | null>(null);
      41            1 :     const ratioWhenDraggingStartedRef = React.useRef(defaultRatio);
      42              : 
      43            1 :     const getRatioBounds = React.useCallback((): [number, number] => {
      44            1 :         const height = containerRef.current?.getBoundingClientRect().height ?? 0;
      45            1 :         const availableHeight = Math.max(0, height - separatorSize);
      46            1 :         if (availableHeight === 0 || availableHeight < minFirstSize + minSecondSize) {
      47            1 :             return [defaultRatio, defaultRatio];
      48              :         }
      49            1 :         return [minFirstSize / availableHeight, 1 - minSecondSize / availableHeight];
      50            1 :     }, [defaultRatio, minFirstSize, minSecondSize, separatorSize]);
      51              : 
      52            1 :     const resizeToPointer = React.useCallback((clientY: number) => {
      53            1 :         const container = containerRef.current;
      54            1 :         if (container == null) return;
      55            1 :         const rect = container.getBoundingClientRect();
      56            1 :         const availableHeight = rect.height - separatorSize;
      57            1 :         if (availableHeight <= 0) return;
      58            1 :         const [minimum, maximum] = getRatioBounds();
      59            1 :         const nextRatio = (clientY - rect.top - separatorSize / 2) / availableHeight;
      60            1 :         setRatio(clamp(nextRatio, minimum, maximum));
      61            1 :     }, [getRatioBounds, separatorSize]);
      62              : 
      63            1 :     React.useLayoutEffect(() => {
      64            1 :         const container = containerRef.current;
      65            1 :         if (container == null) return;
      66            1 :         const updateBounds = () => {
      67            1 :             const bounds = getRatioBounds();
      68            1 :             setRatioBounds(current => current[0] === bounds[0] && current[1] === bounds[1] ? current : bounds);
      69            1 :             setRatio(current => clamp(current, bounds[0], bounds[1]));
      70            1 :         };
      71            1 :         updateBounds();
      72            1 :         const observer = new ResizeObserver(updateBounds);
      73            1 :         observer.observe(container);
      74            1 :         return () => observer.disconnect();
      75            1 :     }, [getRatioBounds]);
      76              : 
      77            1 :     const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
      78            1 :         if (event.button !== 0) return;
      79            1 :         event.preventDefault();
      80            1 :         stopDraggingRef.current?.();
      81            1 :         setIsDragging(true);
      82            1 :         ratioWhenDraggingStartedRef.current = ratio;
      83              : 
      84            1 :         const pointerId = event.pointerId;
      85            1 :         const startY = event.clientY;
      86            1 :         let pointerMoved = false;
      87            1 :         const handlePointerMove = (moveEvent: PointerEvent) => {
      88            1 :             if (moveEvent.pointerId !== pointerId) return;
      89            1 :             moveEvent.preventDefault();
      90            1 :             pointerMoved ||= Math.abs(moveEvent.clientY - startY) >= 3;
      91            1 :             if (!pointerMoved) return;
      92            1 :             resizeToPointer(moveEvent.clientY);
      93            1 :         };
      94            1 :         const stopDragging = () => {
      95            1 :             window.removeEventListener('pointermove', handlePointerMove);
      96            1 :             window.removeEventListener('pointerup', handlePointerEnd);
      97            1 :             window.removeEventListener('pointercancel', handlePointerEnd);
      98            1 :             if (stopDraggingRef.current === stopDragging) stopDraggingRef.current = null;
      99            1 :             setIsDragging(false);
     100            1 :         };
     101            1 :         const handlePointerEnd = (endEvent: PointerEvent) => {
     102            1 :             if (endEvent.pointerId !== pointerId) return;
     103            1 :             if (endEvent.type === 'pointercancel') {
     104            1 :                 setRatio(ratioWhenDraggingStartedRef.current);
     105            1 :             } else if (!pointerMoved) {
     106            1 :                 const [minimum, maximum] = getRatioBounds();
     107            1 :                 setRatio(clamp(1 - ratio, minimum, maximum));
     108            1 :             }
     109            1 :             stopDragging();
     110            1 :         };
     111              : 
     112            1 :         window.addEventListener('pointermove', handlePointerMove);
     113            1 :         window.addEventListener('pointerup', handlePointerEnd);
     114            1 :         window.addEventListener('pointercancel', handlePointerEnd);
     115            1 :         stopDraggingRef.current = stopDragging;
     116            1 :     }, [getRatioBounds, ratio, resizeToPointer]);
     117              : 
     118            1 :     const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
     119            1 :         const [minimum, maximum] = getRatioBounds();
     120            1 :         let nextRatio: number | null = null;
     121            1 :         switch (event.key) {
     122            0 :             case 'ArrowUp':
     123            0 :                 nextRatio = ratio - KEYBOARD_STEP;
     124            0 :                 break;
     125            1 :             case 'ArrowDown':
     126            1 :                 nextRatio = ratio + KEYBOARD_STEP;
     127            1 :                 break;
     128            1 :             case 'Home':
     129            1 :                 nextRatio = minimum;
     130            1 :                 break;
     131            1 :             case 'End':
     132            1 :                 nextRatio = maximum;
     133              :                 break;
     134            1 :         }
     135            1 :         if (nextRatio == null) return;
     136            1 :         event.preventDefault();
     137            1 :         setRatio(clamp(nextRatio, minimum, maximum));
     138            1 :     }, [getRatioBounds, ratio]);
     139              : 
     140            1 :     React.useEffect(() => () => stopDraggingRef.current?.(), []);
     141              : 
     142            1 :     React.useEffect(() => {
     143            1 :         if (props.secondCollapsed) stopDraggingRef.current?.();
     144            1 :     }, [props.secondCollapsed]);
     145              : 
     146            1 :     const ratioPercent = Math.round(ratio * 100);
     147            1 :     const gridTemplateRows = props.secondCollapsed
     148            1 :         ? `minmax(0, 1fr) ${separatorSize}px ${collapsedSecondSize}px`
     149            1 :         : `minmax(${minFirstSize}px, ${ratio}fr) ${separatorSize}px minmax(${minSecondSize}px, ${1 - ratio}fr)`;
     150            1 :     return (
     151            1 :         <div
     152            1 :             ref={containerRef}
     153            1 :             className={classNames(styles.container, props.className, {
     154            1 :                 [styles.dragging]: isDragging,
     155            1 :             })}
     156            1 :             style={{ gridTemplateRows }}
     157              :         >
     158            1 :             <div className={styles.first}>{props.first}</div>
     159            1 :             {props.secondCollapsed ? (
     160            1 :                 <div className={styles.collapsed_separator} aria-hidden="true" />
     161              :             ) : (
     162            1 :                 <div
     163            1 :                     className={classNames(styles.separator, props.separatorClassName)}
     164            1 :                     role="separator"
     165            1 :                     data-electron-drag-region="false"
     166            1 :                     tabIndex={0}
     167            1 :                     aria-label={props.separatorLabel ?? 'Resize panels'}
     168            1 :                     aria-orientation="horizontal"
     169            1 :                     aria-valuemin={Math.round(ratioBounds[0] * 100)}
     170            1 :                     aria-valuemax={Math.round(ratioBounds[1] * 100)}
     171            1 :                     aria-valuenow={ratioPercent}
     172            1 :                     aria-valuetext={`${ratioPercent}% for the top panel`}
     173            1 :                     title="Drag to resize; click to swap panel sizes"
     174            1 :                     onPointerDown={handlePointerDown}
     175            1 :                     onKeyDown={handleKeyDown}
     176              :                 >
     177            1 :                     <span className={styles.handle} aria-hidden="true" />
     178            1 :                 </div>
     179              :             )}
     180            1 :             <div className={styles.second}>{props.second}</div>
     181            1 :         </div>
     182              :     );
     183            1 : };
        

Generated by: LCOV version 2.0-1