feat: Enhance tab UX with drag & drop, middle-click close, context menu, and session management, and resolve tab flicker.
This commit is contained in:
@@ -38,3 +38,7 @@ File riassuntivo dello stato di sviluppo di Zentral.
|
||||
- Implementazione `SchemaDiscoveryService` per allineamento automatico dataset report con strutture dati live.
|
||||
- [2025-12-06 Sidebar Collapsible](./devlog/2025-12-06-010500_sidebar_collapsible.md) - **Completato**
|
||||
- Reso il menu laterale collassabile (manuale e responsive) con visualizzazione a sole icone.
|
||||
- [2025-12-06 Tab UX Improvements](./devlog/2025-12-06-011000_tab_ux_improvements.md) - **Completato**
|
||||
- Miglioramento UX tab: chiusura con middle-click, drag & drop, gruppi di tab personalizzati.
|
||||
- [2025-12-06 Tab Flicker Fix](./devlog/2025-12-06-011500_tab_flicker_fix.md) - **Completato**
|
||||
- Risolto problema di flicker rimuovendo l'aggiornamento manuale dello stato attivo e affidandosi esclusivamente alla sincronizzazione con l'URL.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Tab UX Improvements
|
||||
|
||||
## Overview
|
||||
Improve the user experience of the tab bar above the viewport. The goal is to make it more flexible and user-friendly.
|
||||
|
||||
## Features
|
||||
1. **Middle-click to close**: Allow closing tabs by clicking with the middle mouse button.
|
||||
2. **Drag and Drop**: Allow reordering tabs freely.
|
||||
3. **Tab Groups (Sessions)**: Allow saving the current set of open tabs as a named group/session and restoring it later.
|
||||
4. **Context Menu**: Add a right-click context menu to tabs with options like:
|
||||
- Close
|
||||
- Close Others
|
||||
- Close to Right
|
||||
- Pin Tab (optional, if time permits)
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Dependencies
|
||||
- Install `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`.
|
||||
|
||||
### 2. Context Update (`TabContext.tsx`)
|
||||
- Add `reorderTabs(newOrder: Tab[])` function.
|
||||
- Add state for `tabGroups` (saved in `localStorage`).
|
||||
- Add `saveTabGroup(name: string)` function.
|
||||
- Add `loadTabGroup(name: string)` function.
|
||||
- Add `deleteTabGroup(name: string)` function.
|
||||
- Add `closeOtherTabs(path: string)` function.
|
||||
- Add `closeTabsToRight(path: string)` function.
|
||||
|
||||
### 3. Component Update (`TabsBar.tsx`)
|
||||
- Wrap tabs in `DndContext` and `SortableContext`.
|
||||
- Create a `SortableTab` component.
|
||||
- Implement `onAuxClick` for middle-click closing.
|
||||
- Add a "Tab Groups" button/menu to the right of the tabs.
|
||||
- Show saved groups.
|
||||
- Option to save current session.
|
||||
- Implement a custom Context Menu for tabs.
|
||||
|
||||
## Technical Details
|
||||
- **Storage**: Use `localStorage` for now. Keys: `zentral_tabs`, `zentral_active_tab`, `zentral_tab_groups`.
|
||||
- **Styling**: Use MUI components and system.
|
||||
16
docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md
Normal file
16
docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Tab Flicker Fix
|
||||
|
||||
## Issue
|
||||
The user reports a flicker when clicking a tab before it becomes active.
|
||||
|
||||
## Diagnosis
|
||||
The current implementation of the active tab style uses `borderBottom: isActive ? 2 : 0`. This causes a layout shift (height change or content displacement) of 2px whenever the active state changes. This visual jump is perceived as a flicker.
|
||||
|
||||
## Solution
|
||||
Update the styling to maintain a constant border width but change the color.
|
||||
- Change `borderBottom` to always be `2`.
|
||||
- Change `borderColor` to be `'primary.main'` when active and `'transparent'` when inactive.
|
||||
|
||||
## Plan
|
||||
1. Modify `src/frontend/src/components/TabsBar.tsx`.
|
||||
2. Update `SortableTab` styles.
|
||||
62
src/frontend/package-lock.json
generated
62
src/frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fullcalendar/daygrid": "^6.1.19",
|
||||
@@ -335,6 +338,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
@@ -5587,6 +5643,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fullcalendar/daygrid": "^6.1.19",
|
||||
|
||||
@@ -1,15 +1,165 @@
|
||||
import React from 'react';
|
||||
import { Box, Tabs, Tab, IconButton } from '@mui/material';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
import { useTabs } from '../contexts/TabContext';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Tab, IconButton, Menu, MenuItem, ListItemIcon, ListItemText, Divider, Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions, Tooltip } from '@mui/material';
|
||||
import { Close as CloseIcon, Save as SaveIcon, FolderOpen as FolderOpenIcon, Delete as DeleteIcon, ArrowForward as ArrowForwardIcon, ClearAll as ClearAllIcon } from '@mui/icons-material';
|
||||
import { useTabs, Tab as TabType } from '../contexts/TabContext';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
interface SortableTabProps {
|
||||
tab: TabType;
|
||||
activeTabPath: string;
|
||||
onActivate: (path: string) => void;
|
||||
onClose: (path: string) => void;
|
||||
onContextMenu: (event: React.MouseEvent, tab: TabType) => void;
|
||||
}
|
||||
|
||||
function SortableTab({ tab, activeTabPath, onActivate, onClose, onContextMenu }: SortableTabProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: tab.path });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1 : 'auto',
|
||||
};
|
||||
|
||||
const isActive = activeTabPath === tab.path;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
sx={{ display: 'inline-flex', outline: 'none' }}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button === 1 && tab.closable) {
|
||||
e.preventDefault();
|
||||
onClose(tab.path);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => onContextMenu(e, tab)}
|
||||
>
|
||||
<Tab
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<span>{tab.label}</span>
|
||||
{tab.closable && (
|
||||
<IconButton
|
||||
size="small"
|
||||
component="span"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.path);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
ml: 0.5,
|
||||
width: 20,
|
||||
height: 20,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
color: 'error.main'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
value={tab.path}
|
||||
onClick={() => onActivate(tab.path)}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
color: isActive ? 'primary.main' : 'text.secondary',
|
||||
borderBottom: 2,
|
||||
borderColor: isActive ? 'primary.main' : 'transparent',
|
||||
opacity: 1,
|
||||
minWidth: 'auto',
|
||||
px: 2,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
color: isActive ? 'primary.main' : 'text.primary',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TabsBar() {
|
||||
const { tabs, activeTabPath, setActiveTab, closeTab } = useTabs();
|
||||
const {
|
||||
tabs,
|
||||
activeTabPath,
|
||||
setActiveTab,
|
||||
closeTab,
|
||||
reorderTabs,
|
||||
saveTabGroup,
|
||||
loadTabGroup,
|
||||
tabGroups,
|
||||
deleteTabGroup,
|
||||
closeOtherTabs,
|
||||
closeTabsToRight
|
||||
} = useTabs();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleChange = (_: React.SyntheticEvent, newValue: string) => {
|
||||
setActiveTab(newValue);
|
||||
const [contextMenu, setContextMenu] = useState<{ mouseX: number; mouseY: number; tab: TabType | null } | null>(null);
|
||||
const [groupsMenuAnchor, setGroupsMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
const [saveGroupDialogOpen, setSaveGroupDialogOpen] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = tabs.findIndex((t) => t.path === active.id);
|
||||
const newIndex = tabs.findIndex((t) => t.path === over.id);
|
||||
reorderTabs(arrayMove(tabs, oldIndex, newIndex));
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent, tab: TabType) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({
|
||||
mouseX: event.clientX + 2,
|
||||
mouseY: event.clientY - 6,
|
||||
tab,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu(null);
|
||||
};
|
||||
|
||||
const handleSaveGroup = () => {
|
||||
if (newGroupName.trim()) {
|
||||
saveTabGroup(newGroupName.trim());
|
||||
setNewGroupName('');
|
||||
setSaveGroupDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -21,68 +171,144 @@ export default function TabsBar() {
|
||||
minHeight: 48,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
height: 4,
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: theme.palette.grey[400],
|
||||
borderRadius: 2,
|
||||
},
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTabPath}
|
||||
onChange={handleChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="app tabs"
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 48,
|
||||
textTransform: 'none',
|
||||
minWidth: 'auto',
|
||||
px: 2,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
overflowX: 'auto',
|
||||
display: 'flex',
|
||||
'&::-webkit-scrollbar': { height: 4 },
|
||||
'&::-webkit-scrollbar-track': { backgroundColor: theme.palette.grey[100] },
|
||||
'&::-webkit-scrollbar-thumb': { backgroundColor: theme.palette.grey[400], borderRadius: 2 },
|
||||
}}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={tabs.map(t => t.path)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTab
|
||||
key={tab.path}
|
||||
tab={tab}
|
||||
activeTabPath={activeTabPath}
|
||||
onActivate={setActiveTab}
|
||||
onClose={closeTab}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Box>
|
||||
|
||||
{/* Tab Groups & Actions */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', px: 1, borderLeft: 1, borderColor: 'divider' }}>
|
||||
<Tooltip title="Tab Groups">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setGroupsMenuAnchor(e.currentTarget)}
|
||||
>
|
||||
<FolderOpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Context Menu */}
|
||||
<Menu
|
||||
open={contextMenu !== null}
|
||||
onClose={handleCloseContextMenu}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={
|
||||
contextMenu !== null
|
||||
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.path}
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<span>{tab.label}</span>
|
||||
{tab.closable && (
|
||||
<IconButton
|
||||
size="small"
|
||||
component="span"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.path);
|
||||
}}
|
||||
sx={{
|
||||
p: 0.5,
|
||||
ml: 1,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
color: 'error.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
value={tab.path}
|
||||
/>
|
||||
<MenuItem onClick={() => {
|
||||
if (contextMenu?.tab) closeTab(contextMenu.tab.path);
|
||||
handleCloseContextMenu();
|
||||
}} disabled={!contextMenu?.tab?.closable}>
|
||||
<ListItemIcon><CloseIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Close</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (contextMenu?.tab) closeOtherTabs(contextMenu.tab.path);
|
||||
handleCloseContextMenu();
|
||||
}}>
|
||||
<ListItemIcon><ClearAllIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Close Others</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
if (contextMenu?.tab) closeTabsToRight(contextMenu.tab.path);
|
||||
handleCloseContextMenu();
|
||||
}}>
|
||||
<ListItemIcon><ArrowForwardIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Close to the Right</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Groups Menu */}
|
||||
<Menu
|
||||
anchorEl={groupsMenuAnchor}
|
||||
open={Boolean(groupsMenuAnchor)}
|
||||
onClose={() => setGroupsMenuAnchor(null)}
|
||||
>
|
||||
<MenuItem onClick={() => {
|
||||
setSaveGroupDialogOpen(true);
|
||||
setGroupsMenuAnchor(null);
|
||||
}}>
|
||||
<ListItemIcon><SaveIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Save Current Session</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
{tabGroups.length === 0 && (
|
||||
<MenuItem disabled>
|
||||
<ListItemText secondary="No saved groups" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{tabGroups.map((group) => (
|
||||
<MenuItem key={group.name} onClick={() => {
|
||||
loadTabGroup(group.name);
|
||||
setGroupsMenuAnchor(null);
|
||||
}}>
|
||||
<ListItemIcon><FolderOpenIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{group.name}</ListItemText>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteTabGroup(group.name);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Tabs>
|
||||
</Menu>
|
||||
|
||||
{/* Save Group Dialog */}
|
||||
<Dialog open={saveGroupDialogOpen} onClose={() => setSaveGroupDialogOpen(false)}>
|
||||
<DialogTitle>Save Tab Group</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Group Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSaveGroupDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSaveGroup} variant="contained">Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,24 @@ export interface Tab {
|
||||
closable?: boolean;
|
||||
}
|
||||
|
||||
export interface TabGroup {
|
||||
name: string;
|
||||
tabs: Tab[];
|
||||
}
|
||||
|
||||
interface TabContextType {
|
||||
tabs: Tab[];
|
||||
activeTabPath: string;
|
||||
tabGroups: TabGroup[];
|
||||
openTab: (path: string, label: string, closable?: boolean) => void;
|
||||
closeTab: (path: string) => void;
|
||||
setActiveTab: (path: string) => void;
|
||||
reorderTabs: (newTabs: Tab[]) => void;
|
||||
saveTabGroup: (name: string) => void;
|
||||
loadTabGroup: (name: string) => void;
|
||||
deleteTabGroup: (name: string) => void;
|
||||
closeOtherTabs: (path: string) => void;
|
||||
closeTabsToRight: (path: string) => void;
|
||||
}
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
@@ -20,13 +32,15 @@ const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
export function TabProvider({ children }: { children: ReactNode }) {
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTabPath, setActiveTabPath] = useState<string>('/');
|
||||
const [tabGroups, setTabGroups] = useState<TabGroup[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Load tabs from local storage on mount
|
||||
// Load tabs and groups from local storage on mount
|
||||
useEffect(() => {
|
||||
const savedTabs = localStorage.getItem('zentral_tabs');
|
||||
const savedActiveTab = localStorage.getItem('zentral_active_tab');
|
||||
const savedTabGroups = localStorage.getItem('zentral_tab_groups');
|
||||
|
||||
if (savedTabs) {
|
||||
try {
|
||||
@@ -34,7 +48,6 @@ export function TabProvider({ children }: { children: ReactNode }) {
|
||||
if (Array.isArray(parsedTabs) && parsedTabs.length > 0) {
|
||||
setTabs(parsedTabs);
|
||||
} else {
|
||||
// Default tab
|
||||
setTabs([{ path: '/', label: 'Dashboard', closable: false }]);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -48,6 +61,17 @@ export function TabProvider({ children }: { children: ReactNode }) {
|
||||
if (savedActiveTab) {
|
||||
setActiveTabPath(savedActiveTab);
|
||||
}
|
||||
|
||||
if (savedTabGroups) {
|
||||
try {
|
||||
const parsedGroups = JSON.parse(savedTabGroups);
|
||||
if (Array.isArray(parsedGroups)) {
|
||||
setTabGroups(parsedGroups);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse tab groups", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save tabs to local storage whenever they change
|
||||
@@ -58,22 +82,24 @@ export function TabProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.setItem('zentral_active_tab', activeTabPath);
|
||||
}, [tabs, activeTabPath]);
|
||||
|
||||
// Save groups to local storage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('zentral_tab_groups', JSON.stringify(tabGroups));
|
||||
}, [tabGroups]);
|
||||
|
||||
// Sync active tab with location
|
||||
useEffect(() => {
|
||||
// If the current location is not the active tab, update active tab if it exists in tabs
|
||||
// This handles browser back/forward buttons
|
||||
const currentPath = location.pathname;
|
||||
const tabExists = tabs.find(t => t.path === currentPath);
|
||||
if (tabExists && activeTabPath !== currentPath) {
|
||||
setActiveTabPath(currentPath);
|
||||
}
|
||||
}, [location.pathname, tabs, activeTabPath]);
|
||||
}, [location.pathname, tabs]);
|
||||
|
||||
const openTab = (path: string, label: string, closable: boolean = true) => {
|
||||
if (!tabs.find((t) => t.path === path)) {
|
||||
setTabs((prev) => [...prev, { path, label, closable }]);
|
||||
}
|
||||
setActiveTabPath(path);
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
@@ -81,14 +107,15 @@ export function TabProvider({ children }: { children: ReactNode }) {
|
||||
const tabIndex = tabs.findIndex((t) => t.path === path);
|
||||
if (tabIndex === -1) return;
|
||||
|
||||
// Prevent closing the last tab if it's the dashboard (or make sure dashboard is always there)
|
||||
// But logic here says if we close a tab, we switch to another.
|
||||
|
||||
const newTabs = tabs.filter((t) => t.path !== path);
|
||||
setTabs(newTabs);
|
||||
|
||||
if (activeTabPath === path) {
|
||||
// Switch to the nearest tab
|
||||
const newActiveTab = newTabs[tabIndex] || newTabs[tabIndex - 1] || newTabs[0];
|
||||
if (newActiveTab) {
|
||||
setActiveTabPath(newActiveTab.path);
|
||||
navigate(newActiveTab.path);
|
||||
} else {
|
||||
navigate('/');
|
||||
@@ -97,12 +124,83 @@ export function TabProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const setActiveTab = (path: string) => {
|
||||
setActiveTabPath(path);
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const reorderTabs = (newTabs: Tab[]) => {
|
||||
setTabs(newTabs);
|
||||
};
|
||||
|
||||
const saveTabGroup = (name: string) => {
|
||||
const newGroup: TabGroup = { name, tabs: [...tabs] };
|
||||
setTabGroups(prev => {
|
||||
const existingIndex = prev.findIndex(g => g.name === name);
|
||||
if (existingIndex >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = newGroup;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, newGroup];
|
||||
});
|
||||
};
|
||||
|
||||
const loadTabGroup = (name: string) => {
|
||||
const group = tabGroups.find(g => g.name === name);
|
||||
if (group) {
|
||||
setTabs(group.tabs);
|
||||
if (group.tabs.length > 0) {
|
||||
setActiveTabPath(group.tabs[0].path);
|
||||
navigate(group.tabs[0].path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTabGroup = (name: string) => {
|
||||
setTabGroups(prev => prev.filter(g => g.name !== name));
|
||||
};
|
||||
|
||||
const closeOtherTabs = (path: string) => {
|
||||
const tabToKeep = tabs.find(t => t.path === path);
|
||||
if (tabToKeep) {
|
||||
// Keep dashboard if it exists and is not the current tab?
|
||||
// Usually "Close others" means close everything else that is closable.
|
||||
// Assuming Dashboard is not closable, it should be kept?
|
||||
// The current logic has 'closable' property.
|
||||
|
||||
const newTabs = tabs.filter(t => t.path === path || !t.closable);
|
||||
setTabs(newTabs);
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
const closeTabsToRight = (path: string) => {
|
||||
const index = tabs.findIndex(t => t.path === path);
|
||||
if (index !== -1) {
|
||||
const tabsToKeep = tabs.slice(0, index + 1);
|
||||
// Also keep non-closable tabs that might be to the right (unlikely but good to be safe)
|
||||
const tabsToRight = tabs.slice(index + 1);
|
||||
const nonClosableToRight = tabsToRight.filter(t => !t.closable);
|
||||
|
||||
setTabs([...tabsToKeep, ...nonClosableToRight]);
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabContext.Provider value={{ tabs, activeTabPath, openTab, closeTab, setActiveTab }}>
|
||||
<TabContext.Provider value={{
|
||||
tabs,
|
||||
activeTabPath,
|
||||
tabGroups,
|
||||
openTab,
|
||||
closeTab,
|
||||
setActiveTab,
|
||||
reorderTabs,
|
||||
saveTabGroup,
|
||||
loadTabGroup,
|
||||
deleteTabGroup,
|
||||
closeOtherTabs,
|
||||
closeTabsToRight
|
||||
}}>
|
||||
{children}
|
||||
</TabContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user