LCOV - code coverage report
Current view: top level - dashql-app/src/ui/foundations - vertical_tabs.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 63.3 % 166 105
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 icons from '@ankoh/dashql-svg-symbols';
       5              : import { IconButton, ButtonVariant } from './button.js';
       6              : 
       7              : import * as styles from './vertical_tabs.module.css';
       8              : 
       9            1 : const VSPLIT_DEFAULT_RATIO = 0.4;
      10              : 
      11              : type Key = number;
      12              : 
      13              : export interface VerticalTabRenderers<TabProps extends VerticalTabProps> {
      14              :     [key: Key]: (props: TabProps) => React.ReactElement;
      15              : }
      16              : 
      17              : export interface VerticalTabProps {
      18              :     tabId: number;
      19              :     icon: string;
      20              :     iconActive?: string;
      21              :     labelShort: string;
      22              :     labelLong?: string;
      23              :     disabled?: boolean;
      24              :     ariaLabel?: string;
      25              :     description?: string;
      26              :     /// The group of the tab, consecutive tabs of the same group are separated from other groups by a bar
      27              :     group?: string;
      28              : }
      29              : 
      30            1 : export enum VerticalTabVariant {
      31            1 :     Stacked = 0,
      32            1 : }
      33              : 
      34              : interface Props<TabProps extends VerticalTabProps> {
      35              :     className?: string;
      36              :     variant: VerticalTabVariant;
      37              :     tabKeys: Key[];
      38              :     tabProps: Record<Key, TabProps>;
      39              :     tabRenderers: VerticalTabRenderers<TabProps>;
      40              :     selectedTab: Key;
      41              :     selectTab: (tab: Key) => void;
      42              : 
      43              :     splitModeEnabled?: boolean;
      44              :     splitTab?: Key | null;
      45              :     primaryTabKey?: Key;
      46              :     onToggleSplitMode?: () => void;
      47              :     onSelectSplitTab?: (tab: Key) => void;
      48              : }
      49              : 
      50              : /// Split the tabs into runs of consecutive tabs of the same group
      51            1 : export function groupVerticalTabs<TabProps extends VerticalTabProps>(tabKeys: Key[], tabProps: Record<Key, TabProps>): TabProps[][] {
      52            1 :     const groups: TabProps[][] = [];
      53            1 :     for (const key of tabKeys) {
      54            1 :         const tab = tabProps[key];
      55            1 :         const last = groups[groups.length - 1];
      56            1 :         if (last != null && last[0].group === tab.group) {
      57            1 :             last.push(tab);
      58              :         } else {
      59            1 :             groups.push([tab]);
      60              :         }
      61            1 :     }
      62            1 :     return groups;
      63            1 : }
      64              : 
      65            1 : export function VerticalTabs<TabProps extends VerticalTabProps>(props: Props<TabProps>): React.ReactElement {
      66            1 :     const [splitRatio, setSplitRatio] = React.useState(VSPLIT_DEFAULT_RATIO);
      67            1 :     const [isDragging, setIsDragging] = React.useState(false);
      68            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
      69              : 
      70            1 :     const selectTab = React.useCallback((tabId: number) => {
      71            1 :         if (props.splitModeEnabled) {
      72              :             // In split mode, clicking primary tab does nothing
      73            0 :             if (tabId === props.primaryTabKey) {
      74            0 :                 return;
      75              :             }
      76              :             // Clicking other tabs sets them as the split tab
      77            0 :             if (props.onSelectSplitTab) {
      78            0 :                 props.onSelectSplitTab(tabId);
      79              :             }
      80            0 :         } else {
      81              :             // Normal mode: select the tab as the main tab
      82            1 :             props.selectTab(tabId);
      83              :         }
      84            1 :     }, [props.splitModeEnabled, props.primaryTabKey, props.onSelectSplitTab, props.selectTab]);
      85              : 
      86            1 :     const handleResizeStart = React.useCallback((e: React.MouseEvent) => {
      87            0 :         e.preventDefault();
      88            0 :         setIsDragging(true);
      89            1 :     }, []);
      90              : 
      91            1 :     const handleResizeMove = React.useCallback((e: MouseEvent) => {
      92            0 :         if (!isDragging || !containerRef.current) return;
      93              : 
      94            0 :         const container = containerRef.current;
      95            0 :         const rect = container.getBoundingClientRect();
      96            0 :         const y = e.clientY - rect.top;
      97            0 :         const newRatio = Math.max(0.2, Math.min(0.8, y / rect.height));
      98            0 :         setSplitRatio(newRatio);
      99            1 :     }, [isDragging]);
     100              : 
     101            1 :     const handleResizeEnd = React.useCallback(() => {
     102            0 :         setIsDragging(false);
     103            1 :     }, []);
     104              : 
     105            1 :     React.useEffect(() => {
     106            1 :         if (isDragging) {
     107            0 :             document.addEventListener('mousemove', handleResizeMove);
     108            0 :             document.addEventListener('mouseup', handleResizeEnd);
     109            0 :             return () => {
     110            0 :                 document.removeEventListener('mousemove', handleResizeMove);
     111            0 :                 document.removeEventListener('mouseup', handleResizeEnd);
     112            0 :             };
     113            0 :         }
     114            1 :     }, [isDragging, handleResizeMove, handleResizeEnd]);
     115              : 
     116              :     // Render primary tab body.
     117              :     // The bodies are memoized to not re-render them for every mouse move while dragging the split.
     118            1 :     const primaryTabKey = props.splitModeEnabled ? props.primaryTabKey ?? props.selectedTab : props.selectedTab;
     119            1 :     const primaryTabRenderer = props.tabRenderers[primaryTabKey];
     120            1 :     const primaryTabBodyProps = props.tabProps[primaryTabKey];
     121            1 :     const primaryTabBody = React.useMemo(
     122            1 :         () => primaryTabRenderer ? primaryTabRenderer(primaryTabBodyProps) : undefined,
     123            1 :         [primaryTabRenderer, primaryTabBodyProps],
     124            1 :     );
     125              : 
     126              :     // Render split tab body if split mode is enabled
     127            1 :     const splitTabRenderer = (props.splitModeEnabled && props.splitTab != null) ? props.tabRenderers[props.splitTab] : null;
     128            1 :     const splitTabBodyProps = props.splitTab != null ? props.tabProps[props.splitTab] : undefined;
     129            1 :     const splitTabBody = React.useMemo(
     130            1 :         () => splitTabRenderer ? splitTabRenderer(splitTabBodyProps!) : undefined,
     131            1 :         [splitTabRenderer, splitTabBodyProps],
     132            1 :     );
     133              : 
     134            1 :     const renderStackedTab = (tabProps: VerticalTabProps) => {
     135            1 :         const isPrimaryTab = props.splitModeEnabled && tabProps.tabId === props.primaryTabKey;
     136            1 :         const isActive = isPrimaryTab || tabProps.tabId == props.selectedTab || (props.splitModeEnabled && tabProps.tabId === props.splitTab);
     137            1 :         const isSplitTab = props.splitModeEnabled && tabProps.tabId === props.splitTab;
     138            1 :         const showSplitIndicator = isPrimaryTab || isSplitTab;
     139            1 :         const splitNumber = isPrimaryTab ? '1' : '2';
     140              : 
     141            1 :         const tooltipText = tabProps.description || tabProps.ariaLabel || tabProps.labelShort;
     142            1 :         const ariaLabel = tabProps.ariaLabel || tabProps.labelShort;
     143              : 
     144            1 :         return (
     145            1 :             <div
     146            1 :                 key={tabProps.tabId}
     147            1 :                 className={classNames(styles.stacked_tab, {
     148            1 :                     [styles.stacked_tab_active]: isActive,
     149            1 :                 })}
     150            1 :                 data-tab={tabProps.tabId}
     151              :             >
     152            1 :                 <IconButton
     153            1 :                     className={classNames(styles.stacked_tab_icon, {
     154            1 :                         [styles.stacked_tab_icon_split]: showSplitIndicator,
     155            1 :                     })}
     156            1 :                     variant={ButtonVariant.Invisible}
     157            1 :                     aria-label={ariaLabel}
     158            1 :                     aria-current={isActive ? 'page' : undefined}
     159            1 :                     description={tooltipText}
     160            1 :                     disabled={tabProps.disabled}
     161            1 :                     onClick={() => selectTab(tabProps.tabId)}
     162              :                 >
     163            1 :                     {showSplitIndicator ? (
     164            0 :                         <>
     165            0 :                             <div className={styles.stacked_tab_icon_half_first}>
     166            0 :                                 <span className={styles.stacked_tab_split_number}>{splitNumber}</span>
     167            0 :                             </div>
     168            0 :                             <div className={styles.stacked_tab_icon_half}>
     169            0 :                                 <svg width="12px" height="12px">
     170            0 :                                     <use xlinkHref={(isActive && tabProps.iconActive) ? tabProps.iconActive : tabProps.icon} />
     171            0 :                                 </svg>
     172            0 :                             </div>
     173            0 :                         </>
     174              :                     ) : (
     175            1 :                         <svg width="18px" height="16px">
     176            1 :                             <use xlinkHref={(isActive && tabProps.iconActive) ? tabProps.iconActive : tabProps.icon} />
     177            1 :                         </svg>
     178              :                     )}
     179            1 :                 </IconButton>
     180            1 :             </div>
     181              :         );
     182            1 :     };
     183              : 
     184              :     // Check if there are at least 2 enabled tabs (so split mode makes sense)
     185            1 :     const enabledTabCount = props.tabKeys.filter(key => !props.tabProps[key]?.disabled).length;
     186            1 :     const canEnableSplit = enabledTabCount >= 2;
     187              : 
     188            1 :     const gridStyle = props.splitModeEnabled ? {
     189            0 :         gridTemplateRows: `${splitRatio * 100}% max-content 1fr`,
     190            1 :     } : undefined;
     191              : 
     192            1 :     return (
     193            1 :         <div
     194            1 :             ref={containerRef}
     195            1 :             className={classNames(props.className, styles.container, {
     196            1 :                 [styles.container_split]: props.splitModeEnabled,
     197            1 :             })}
     198            1 :             style={gridStyle}
     199              :         >
     200            1 :             <div className={styles.tabs}>
     201            1 :                 <div className={styles.tabs_list}>
     202            1 :                     {groupVerticalTabs(props.tabKeys, props.tabProps).map((group, i) => (
     203            1 :                         <React.Fragment key={group[0].tabId}>
     204            1 :                             {i > 0 && <div className={styles.tabs_separator} role="separator" aria-orientation="horizontal" />}
     205            1 :                             <div className={styles.tabs_group} role="group" aria-label={group[0].group}>
     206            1 :                                 {group.map(renderStackedTab)}
     207            1 :                             </div>
     208            1 :                         </React.Fragment>
     209            1 :                     ))}
     210            1 :                 </div>
     211            1 :                 {props.onToggleSplitMode && (
     212            0 :                     <div className={styles.tabs_footer}>
     213            0 :                         <div
     214            0 :                             className={classNames(styles.split_toggle, {
     215            0 :                                 [styles.split_toggle_active]: props.splitModeEnabled,
     216            0 :                             })}
     217              :                         >
     218            0 :                             <IconButton
     219            0 :                                 className={styles.split_toggle_icon}
     220            0 :                                 variant={ButtonVariant.Invisible}
     221            0 :                                 aria-label="Toggle split"
     222            0 :                                 description="Toggle split"
     223            0 :                                 disabled={!canEnableSplit}
     224            0 :                                 onClick={props.onToggleSplitMode}
     225              :                             >
     226            0 :                                 <svg width="18px" height="16px">
     227            0 :                                     <use xlinkHref={`${icons}#vsplit_24`} />
     228            0 :                                 </svg>
     229            0 :                             </IconButton>
     230            0 :                         </div>
     231            0 :                     </div>
     232              :                 )}
     233            1 :             </div>
     234            1 :             <div className={styles.primary_body}>{primaryTabBody}</div>
     235            1 :             {props.splitModeEnabled && (
     236            0 :                 <>
     237            0 :                     <div
     238            0 :                         className={classNames(styles.resize_handle, {
     239            0 :                             [styles.resize_handle_dragging]: isDragging,
     240            0 :                         })}
     241            0 :                         onMouseDown={handleResizeStart}
     242              :                     >
     243            0 :                         <div className={styles.resize_handle_bar} />
     244            0 :                     </div>
     245            0 :                     {splitTabBody && (
     246            0 :                         <div className={styles.split_body}>{splitTabBody}</div>
     247              :                     )}
     248            0 :                 </>
     249              :             )}
     250            1 :         </div>
     251              :     );
     252            1 : };
        

Generated by: LCOV version 2.0-1