diff --git a/docs/development/ZENTRAL.md b/docs/development/ZENTRAL.md index f59ab41..c6da213 100644 --- a/docs/development/ZENTRAL.md +++ b/docs/development/ZENTRAL.md @@ -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. diff --git a/docs/development/devlog/2025-12-06-011000_tab_ux_improvements.md b/docs/development/devlog/2025-12-06-011000_tab_ux_improvements.md new file mode 100644 index 0000000..e078ad9 --- /dev/null +++ b/docs/development/devlog/2025-12-06-011000_tab_ux_improvements.md @@ -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. diff --git a/docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md b/docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md new file mode 100644 index 0000000..b54736e --- /dev/null +++ b/docs/development/devlog/2025-12-06-011500_tab_flicker_fix.md @@ -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. diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index b41411a..2e79e53 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -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", diff --git a/src/frontend/package.json b/src/frontend/package.json index e28a2cc..ff72f36 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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", diff --git a/src/frontend/src/components/TabsBar.tsx b/src/frontend/src/components/TabsBar.tsx index 0cf18fc..c4d758a 100644 --- a/src/frontend/src/components/TabsBar.tsx +++ b/src/frontend/src/components/TabsBar.tsx @@ -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 ( + { + if (e.button === 1 && tab.closable) { + e.preventDefault(); + onClose(tab.path); + } + }} + onContextMenu={(e) => onContextMenu(e, tab)} + > + + {tab.label} + {tab.closable && ( + { + e.stopPropagation(); + onClose(tab.path); + }} + sx={{ + p: 0.5, + ml: 0.5, + width: 20, + height: 20, + '&:hover': { + bgcolor: 'action.hover', + color: 'error.main' + } + }} + > + + + )} + + } + 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', + } + }} + /> + + ); +} 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); + 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', }} > - + + t.path)} + strategy={horizontalListSortingStrategy} + > + {tabs.map((tab) => ( + + ))} + + + + + {/* Tab Groups & Actions */} + + + setGroupsMenuAnchor(e.currentTarget)} + > + + + + + + {/* Context Menu */} + - {tabs.map((tab) => ( - - {tab.label} - {tab.closable && ( - { - e.stopPropagation(); - closeTab(tab.path); - }} - sx={{ - p: 0.5, - ml: 1, - '&:hover': { - bgcolor: 'action.hover', - color: 'error.main', - }, - }} - > - - - )} - - } - value={tab.path} - /> + { + if (contextMenu?.tab) closeTab(contextMenu.tab.path); + handleCloseContextMenu(); + }} disabled={!contextMenu?.tab?.closable}> + + Close + + { + if (contextMenu?.tab) closeOtherTabs(contextMenu.tab.path); + handleCloseContextMenu(); + }}> + + Close Others + + { + if (contextMenu?.tab) closeTabsToRight(contextMenu.tab.path); + handleCloseContextMenu(); + }}> + + Close to the Right + + + + {/* Groups Menu */} + setGroupsMenuAnchor(null)} + > + { + setSaveGroupDialogOpen(true); + setGroupsMenuAnchor(null); + }}> + + Save Current Session + + + {tabGroups.length === 0 && ( + + + + )} + {tabGroups.map((group) => ( + { + loadTabGroup(group.name); + setGroupsMenuAnchor(null); + }}> + + {group.name} + { + e.stopPropagation(); + deleteTabGroup(group.name); + }} + > + + + ))} - + + + {/* Save Group Dialog */} + setSaveGroupDialogOpen(false)}> + Save Tab Group + + setNewGroupName(e.target.value)} + /> + + + + + + ); } diff --git a/src/frontend/src/contexts/TabContext.tsx b/src/frontend/src/contexts/TabContext.tsx index e157cc0..9fe8054 100644 --- a/src/frontend/src/contexts/TabContext.tsx +++ b/src/frontend/src/contexts/TabContext.tsx @@ -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(undefined); @@ -20,13 +32,15 @@ const TabContext = createContext(undefined); export function TabProvider({ children }: { children: ReactNode }) { const [tabs, setTabs] = useState([]); const [activeTabPath, setActiveTabPath] = useState('/'); + const [tabGroups, setTabGroups] = useState([]); 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 ( - + {children} );