Skip to content
Snippets Groups Projects
Commit f5f66063 authored by duncan's avatar duncan Committed by Dennis Collaris
Browse files

Sortable tabs ux

parent 8d8790a0
No related branches found
No related tags found
1 merge request!383Sortable tabs ux
Pipeline #144850 failed
No preview for this file type
......@@ -87,6 +87,7 @@
"reorder.js": "^2.2.6",
"sass": "^1.83.0",
"scss": "^0.2.4",
"sortablejs": "^1.15.6",
"ts-common": "link:ts-common",
"use-immer": "^0.11.0"
},
......
......@@ -15,3 +15,4 @@ export * from './LoadingSpinner';
export * from './Popup';
export * from './layout';
export * from './pills';
export * from './tabs';
import React from 'react';
import { ButtonProps } from '../buttons';
import React, { forwardRef } from 'react';
import { ButtonProps } from '@/lib/components/buttons';
type TabTypes = 'inline' | 'rounded' | 'simple';
......@@ -10,25 +10,31 @@ const TabContext = React.createContext<ContextType>({
tabType: 'inline',
});
export const Tabs = (props: { children: React.ReactNode; tabType?: TabTypes; className?: string; }) => {
const tabType = props.tabType || 'inline';
let className = '';
export const Tabs = forwardRef<HTMLDivElement, {
children: React.ReactNode;
tabType?: TabTypes;
className?: string;
}>((props, ref) => {
const { children, tabType = 'inline', className = '' } = props;
let baseClass = '';
if (tabType === 'inline') {
className = 'flex items-stretch';
baseClass = 'flex items-stretch';
} else if (tabType === 'rounded') {
className = 'flex gap-x-1 relative before:w-full before:h-px before:absolute before:bottom-0 before:bg-secondary-200 overflow-hidden';
baseClass = 'flex gap-x-1 relative before:w-full before:h-px before:absolute before:bottom-0 before:bg-secondary-200 overflow-hidden';
} else if (tabType === 'simple') {
className = 'flex';
baseClass = 'flex';
}
const combinedClassName = `${className} ${props.className || ''}`;
const combinedClass = `${baseClass} ${className}`.trim();
return (
<TabContext.Provider value={{ tabType: tabType }}>
<div className={combinedClassName.trim()}>
{props.children}
<TabContext.Provider value={{ tabType : tabType}}>
<div ref={ref} className={combinedClass}>
{children}
</div>
</TabContext.Provider>
);
};
});
Tabs.displayName = 'Tabs';
export const Tab = ({
activeTab,
......@@ -47,18 +53,29 @@ export const Tab = ({
if (context.tabType === 'inline') {
className += ` px-2 gap-1 relative h-full max-w-64 flex-nowrap before:content-['']
before:absolute before:left-0 before:bottom-0 before:h-[2px] before:w-full
${activeTab ? 'before:bg-primary-500 hover:bg-inherit text-dark ' : ' text-secondary-600 hover:text-dark hover:bg-secondary-200 hover:bg-opacity-50 before:bg-transparent hover:before:bg-secondary-300'}`;
${activeTab
? 'before:bg-primary-500 text-dark'
: ' text-secondary-600 hover:text-dark hover:bg-secondary-200 hover:bg-opacity-50 before:bg-transparent hover:before:bg-secondary-300'
}`;
} else if (context.tabType === 'rounded') {
className += ` py-1.5 px-3 -mb-px text-sm flex-nowrap text-center border border-secondary-200 rounded-t
${activeTab ? 'active z-[2] text-dark bg-light' : 'bg-secondary-100 hover:text-dark border-secondary-100 text-secondary-600'}`;
${activeTab
? 'active z-[2] text-dark bg-light'
: 'bg-secondary-100 hover:text-dark border-secondary-100 text-secondary-600'
}`;
} else if (context.tabType === 'simple') {
className += ` px-2 py-1 gap-1 ${activeTab ? 'active bg-secondary-100 text-dark' : 'text-secondary-600 hover:text-dark'}`;
className += ` px-2 py-1 gap-1 ${activeTab ? 'active text-dark' : 'text-secondary-500 hover:text-dark'}`;
}
return (
<div className={`cursor-pointer relative text-xs font-medium flex items-center justify-start ${className}`} {...props} tabIndex={0}>
<div
className={` cursor-pointer relative text-xs font-medium flex items-center justify-start ${className}`}
{...props}
tabIndex={0}
data-type="tab"
>
{IconComponent && <IconComponent className="h-4 w-4 shrink-0 pointer-events-none" />}
<span className="truncate">{text}</span>
<span className="truncate select-none">{text}</span>
{props.children}
</div>
);
......
......@@ -14,6 +14,13 @@ export const initialState: VisState = {
activeVisualizationIndex: -1,
openVisualizationArray: [],
};
function generateUUID(): string {
if (typeof crypto?.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export const visualizationSlice = createSlice({
name: 'visualization',
......@@ -43,8 +50,13 @@ export const visualizationSlice = createSlice({
},
setVisualizationState: (state, action: PayloadAction<VisState>) => {
if (action.payload.activeVisualizationIndex !== undefined && !isEqual(action.payload, state)) {
state.openVisualizationArray = action.payload.openVisualizationArray || [];
state.activeVisualizationIndex = Math.min(action.payload.activeVisualizationIndex, state.openVisualizationArray.length - 1);
state.openVisualizationArray = (action.payload.openVisualizationArray || []).map(item => ({
...item, uuid: item.uuid || generateUUID(),
}));
state.activeVisualizationIndex = Math.min(
action.payload.activeVisualizationIndex,
state.openVisualizationArray.length - 1
);
}
},
updateVisualization: (state, action: PayloadAction<{ id: number; settings: VisualizationSettingsType }>) => {
......@@ -54,7 +66,13 @@ export const visualizationSlice = createSlice({
}
},
addVisualization: (state, action: PayloadAction<VisualizationSettingsType>) => {
state.openVisualizationArray.push(action.payload);
const newItem = {
...action.payload,
uuid: action.payload.uuid || generateUUID(),
};
state.openVisualizationArray.push(newItem);
// state.openVisualizationArray.push(action.payload);
state.activeVisualizationIndex = state.openVisualizationArray.length - 1;
},
updateActiveVisualization: (state, action: PayloadAction<VisualizationSettingsType>) => {
......@@ -82,6 +100,7 @@ export const visualizationSlice = createSlice({
const settingsCopy = [...state.openVisualizationArray];
const [movedItem] = settingsCopy.splice(id, 1);
settingsCopy.splice(newPosition, 0, movedItem);
state.openVisualizationArray = settingsCopy;
if (state.activeVisualizationIndex === id) {
......
......@@ -5,6 +5,7 @@ import type { AppDispatch } from '../../data-access';
import { ML, GraphStatistics, NodeQueryResult, EdgeQueryResult, XYPosition } from 'ts-common';
export type VisualizationSettingsType = {
uuid: string; // unique identifier for the visualization
id: string;
name: string;
[id: string]: any;
......
import React, { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Button, DropdownContainer, DropdownItem, DropdownItemContainer, DropdownTrigger } from '../../components';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../components/tooltip';
import { ControlContainer } from '../../components/controls';
import { Tabs, Tab } from '../../components/tabs';
import { useActiveSaveState, useActiveSaveStateAuthorization, useAppDispatch, useSessionCache, useVisualization } from '../../data-access';
import { Tabs, Tab } from "@/lib/components";
import { useActiveSaveStateAuthorization, useAppDispatch, useVisualization } from '../../data-access';
import { addVisualization, removeVisualization, reorderVisState, setActiveVisualization } from '../../data-access/store/visualizationSlice';
import { VisualizationsConfig } from './config';
import { Visualizations } from './VisualizationPanel';
import Sortable from 'sortablejs';
export default function VisualizationTabBar(props: { fullSize: () => void; exportImage: () => void; handleSelect: () => void }) {
const { activeVisualizationIndex, openVisualizationArray } = useVisualization();
......@@ -14,30 +15,40 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor
const [open, setOpen] = useState(false);
const dispatch = useAppDispatch();
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, i: number) => {
e.dataTransfer.setData('text/plain', i.toString());
};
const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
};
const tabsRef = useRef<HTMLDivElement | null>(null);
const handleDrop = (e: React.DragEvent<HTMLButtonElement>, i: number) => {
e.preventDefault();
const draggedVisIndex = e.dataTransfer.getData('text/plain');
dispatch(reorderVisState({ id: Number(draggedVisIndex), newPosition: i }));
};
const onSelect = async (id?: number) => {
if (id === undefined) return;
dispatch(setActiveVisualization(id));
};
useEffect(() => {
if (!tabsRef.current) return;
const sortable = new Sortable(tabsRef.current, {
animation: 150,
draggable: "[data-type=\"tab\"]",
ghostClass: "bg-secondary-300",
dragClass: "bg-secondary-100",
onEnd: (evt) => {
if (
evt.oldIndex != null &&
evt.newIndex != null &&
evt.oldIndex !== evt.newIndex
) {
dispatch(
reorderVisState({
id: evt.oldIndex,
newPosition: evt.newIndex,
})
);
}
},
});
const onDelete = (id: number) => {
dispatch(removeVisualization(id));
props.handleSelect();
};
return () => {
sortable.destroy();
};
}, [dispatch, openVisualizationArray]);
/**
* User can export image with Ctrl+S
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
......@@ -53,6 +64,16 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor
};
}, [props]);
const onSelect = async (idx?: number) => {
if (idx === undefined) return;
dispatch(setActiveVisualization(idx));
};
const onDelete = (idx: number) => {
dispatch(removeVisualization(idx));
props.handleSelect();
};
return (
<div className="absolute shrink-0 top-0 left-0 right-0 flex items-stretch justify-start h-7 bg-secondary-100 border-b border-secondary-200 max-w-full">
<div className="flex items-center px-2">
......@@ -94,48 +115,44 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor
{openVisualizationArray.length > 0 && (
<Tabs className="-my-px overflow-x-auto overflow-y-hidden no-scrollbar divide-x divide-secondary-200 border-x">
<Tabs ref={tabsRef} className="-my-px overflow-x-auto overflow-y-hidden no-scrollbar divide-x divide-secondary-200 border-x">
{openVisualizationArray.map((vis, i) => {
const isActive = activeVisualizationIndex === i;
const config = VisualizationsConfig[vis.id];
const IconComponent = config?.icons.sm;
return (
<Tab
key={i}
activeTab={isActive}
text={vis.name}
IconComponent={IconComponent}
className="group"
onClick={() => onSelect(i)}
onDragStart={e => handleDragStart(e, i)}
onDragOver={e => handleDragOver(e)}
onDrop={e => handleDrop(e, i)}
draggable
>
<Button
variantType="secondary"
variant="ghost"
disabled={!saveStateAuthorization.database?.W}
rounded
size="2xs"
iconComponent="icon-[ic--baseline-close]"
className={!isActive ? 'opacity-50 group-hover:opacity-100 group-focus-within:opacity-100' : ''}
onClick={e => {
e.stopPropagation();
onDelete(i);
}}
/>
</Tab>
);
})}
</Tabs>
return (
<Tab
key={vis.uuid}
activeTab={isActive}
text={vis.name}
IconComponent={IconComponent}
className="group"
onClick={() => onSelect(i)}
>
{/*{vis.id},{config.id}*/}
<Button
variantType="secondary"
variant="ghost"
disabled={!saveStateAuthorization.database?.W}
rounded
size="2xs"
iconComponent="icon-[ic--baseline-close]"
className={!isActive ? 'opacity-50 group-hover:opacity-100 group-focus-within:opacity-100' : ''}
onClick={(e) => {
e.stopPropagation();
onDelete(i);
}}
/>
</Tab>
);
})}
</Tabs>
)}
{openVisualizationArray.length > 0 && (
<div className="shrink-0 sticky right-0 px-0.5 ml-auto flex">
<ControlContainer>
<div className="shrink-0 sticky right-0 px-0.5 ml-auto flex">
<ControlContainer>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
......@@ -166,9 +183,8 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor
</TooltipContent>
</Tooltip>
</TooltipProvider>
</ControlContainer>
</div>
</ControlContainer>
</div>
)}
</div>
);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment