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 : };
|