feat(dashboard): implement responsive header with tab scrolling and overflow menus
Add comprehensive responsive header system with Google-quality UX: Tab Navigation: - Add TabScrollManager for horizontal scrolling tabs - Left/Right navigation arrows appear when scrollable - Edge fade indicators show more content exists - Smooth scroll behavior with momentum - Progressive sizing: full → icon+name → icon-only - Automatic scroll position tracking Button Overflow System: - Add HeaderOverflowManager with ResizeObserver - Three responsive modes based on container width: * Full mode (>900px): all buttons visible * Overflow mode (500-900px): priority + "More" (⋮) menu * Compact mode (<500px): priority + hamburger (☰) menu - Priority buttons (Lock + Edit) always visible - Smooth transitions between modes Dropdown Menu: - Professional slide-down animation - Full keyboard navigation (arrows, Home, End, Escape) - Click-outside-to-close behavior - ARIA attributes for accessibility - Focus management and trap - Auto-refresh on edit mode changes - High z-index (10003) ensures visibility above all UI Cross-Tab Widget Dragging: - Add collision detection for widget placement - Implement moveWidgetToTab() with collision avoidance - Find available positions in target tab automatically - Update dragDrop.js to detect tab hover - Visual feedback with tab highlight on hover - Proper widget positioning after cross-tab move Additional Features: - Sort Tab button for current-tab-only auto-layout - Mobile optimizations with compact buttons - Responsive breakpoints at 768px and 480px - Hardware-accelerated animations - Touch-friendly 44px minimum targets Files changed: - New: tabScrollManager.js, headerOverflowManager.js - Modified: dashboardTemplate.html, dashboardIntegration.js - Modified: dashboardManager.js, dragDrop.js, style.css - Modified: editModeManager.js (lock state default)
This commit is contained in:
@@ -12,6 +12,8 @@ import { renderExtensionTemplateAsync } from '../../../../../../extensions.js';
|
||||
import { DashboardManager } from './dashboardManager.js';
|
||||
import { WidgetRegistry } from './widgetRegistry.js';
|
||||
import { generateDefaultDashboard } from './defaultLayout.js';
|
||||
import { TabScrollManager } from './tabScrollManager.js';
|
||||
import { HeaderOverflowManager } from './headerOverflowManager.js';
|
||||
|
||||
// Widget imports
|
||||
import { registerUserInfoWidget } from './widgets/userInfoWidget.js';
|
||||
@@ -24,6 +26,8 @@ import { registerInventoryWidget } from './widgets/inventoryWidget.js';
|
||||
|
||||
// Global dashboard manager instance
|
||||
let dashboardManager = null;
|
||||
let tabScrollManager = null;
|
||||
let headerOverflowManager = null;
|
||||
|
||||
/**
|
||||
* Get the dashboard manager instance
|
||||
@@ -101,6 +105,20 @@ export async function initializeDashboard(dependencies) {
|
||||
// Set up dashboard event listeners
|
||||
setupDashboardEventListeners(dependencies);
|
||||
|
||||
// Initialize tab scroll manager
|
||||
const tabsContainer = document.querySelector('#rpg-dashboard-tabs');
|
||||
if (tabsContainer) {
|
||||
tabScrollManager = new TabScrollManager(tabsContainer);
|
||||
tabScrollManager.init();
|
||||
}
|
||||
|
||||
// Initialize header overflow manager
|
||||
const headerRight = document.querySelector('#rpg-dashboard-header-right');
|
||||
if (headerRight) {
|
||||
headerOverflowManager = new HeaderOverflowManager(headerRight);
|
||||
headerOverflowManager.init();
|
||||
}
|
||||
|
||||
console.log('[RPG Companion] Dashboard v2 initialized successfully');
|
||||
return dashboardManager;
|
||||
|
||||
@@ -227,6 +245,17 @@ function setupDashboardEventListeners(dependencies) {
|
||||
});
|
||||
}
|
||||
|
||||
// Sort Tab button (layout current tab only)
|
||||
const sortTabBtn = document.querySelector('#rpg-dashboard-sort-tab');
|
||||
if (sortTabBtn) {
|
||||
sortTabBtn.addEventListener('click', () => {
|
||||
if (dashboardManager) {
|
||||
console.log('[RPG Companion] Sort tab button clicked');
|
||||
dashboardManager.autoLayoutCurrentTab();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Edit mode toggle
|
||||
const editModeBtn = document.querySelector('#rpg-dashboard-edit-mode');
|
||||
if (editModeBtn) {
|
||||
@@ -234,6 +263,10 @@ function setupDashboardEventListeners(dependencies) {
|
||||
if (dashboardManager && dashboardManager.editManager) {
|
||||
console.log('[RPG Companion] Edit button clicked');
|
||||
dashboardManager.editManager.toggleEditMode();
|
||||
// Refresh header overflow menu to reflect edit mode button visibility changes
|
||||
if (headerOverflowManager) {
|
||||
setTimeout(() => headerOverflowManager.refresh(), 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -256,6 +289,10 @@ function setupDashboardEventListeners(dependencies) {
|
||||
if (dashboardManager && dashboardManager.editManager) {
|
||||
console.log('[RPG Companion] Done button clicked');
|
||||
dashboardManager.editManager.exitEditMode(true); // Save changes
|
||||
// Refresh header overflow menu to reflect edit mode button visibility changes
|
||||
if (headerOverflowManager) {
|
||||
setTimeout(() => headerOverflowManager.refresh(), 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,11 +166,12 @@ export class DashboardManager {
|
||||
onWidgetSettings: (widgetId) => this.openWidgetSettings(widgetId)
|
||||
});
|
||||
|
||||
// Initialize Drag & Drop (with editManager reference)
|
||||
// Initialize Drag & Drop (with editManager and dashboardManager references)
|
||||
this.dragHandler = new DragDropHandler(this.gridEngine, {
|
||||
showGrid: true,
|
||||
enableSnap: true,
|
||||
editManager: this.editManager
|
||||
editManager: this.editManager,
|
||||
dashboardManager: this
|
||||
});
|
||||
|
||||
// Initialize Resize Handler (with editManager reference)
|
||||
@@ -489,6 +490,106 @@ export class DashboardManager {
|
||||
this.notifyChange('widgetRemoved', { widgetId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a widget from one tab to another
|
||||
* @param {string} widgetId - Widget ID to move
|
||||
* @param {string} targetTabId - Target tab ID
|
||||
*/
|
||||
moveWidgetToTab(widgetId, targetTabId) {
|
||||
console.log(`[DashboardManager] Moving widget ${widgetId} to tab ${targetTabId}`);
|
||||
|
||||
// Find which tab currently contains the widget
|
||||
let sourceTab = null;
|
||||
let widgetData = null;
|
||||
|
||||
for (const tab of this.dashboard.tabs) {
|
||||
if (tab.widgets) {
|
||||
const index = tab.widgets.findIndex(w => w.id === widgetId);
|
||||
if (index !== -1) {
|
||||
sourceTab = tab;
|
||||
widgetData = tab.widgets[index];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceTab || !widgetData) {
|
||||
console.warn(`[DashboardManager] Widget ${widgetId} not found in any tab`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get target tab
|
||||
const targetTab = this.tabManager.getTab(targetTabId);
|
||||
if (!targetTab) {
|
||||
console.warn(`[DashboardManager] Target tab ${targetTabId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't move if already in target tab
|
||||
if (sourceTab.id === targetTabId) {
|
||||
console.log(`[DashboardManager] Widget ${widgetId} already in tab ${targetTabId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from source tab
|
||||
const index = sourceTab.widgets.findIndex(w => w.id === widgetId);
|
||||
sourceTab.widgets.splice(index, 1);
|
||||
|
||||
// Find available position in target tab (collision detection)
|
||||
if (!targetTab.widgets) {
|
||||
targetTab.widgets = [];
|
||||
}
|
||||
|
||||
// Find available position explicitly checking against target tab widgets
|
||||
const availablePosition = this.findAvailablePositionInWidgets(
|
||||
{ w: widgetData.w, h: widgetData.h },
|
||||
targetTab.widgets
|
||||
);
|
||||
|
||||
widgetData.x = availablePosition.x;
|
||||
widgetData.y = availablePosition.y;
|
||||
|
||||
console.log(`[DashboardManager] Found available position in target tab: (${availablePosition.x}, ${availablePosition.y})`);
|
||||
|
||||
// Add to target tab
|
||||
targetTab.widgets.push(widgetData);
|
||||
|
||||
// Update runtime widget data if it exists
|
||||
const runtimeData = this.widgets.get(widgetId);
|
||||
if (runtimeData) {
|
||||
runtimeData.tabId = targetTabId;
|
||||
}
|
||||
|
||||
// Update DOM if source or target is current tab
|
||||
if (sourceTab.id === this.currentTabId || targetTabId === this.currentTabId) {
|
||||
// If widget is being moved from current tab, remove its element
|
||||
if (sourceTab.id === this.currentTabId && runtimeData) {
|
||||
const definition = this.registry.get(widgetData.type);
|
||||
if (definition && definition.onRemove) {
|
||||
definition.onRemove(runtimeData.element, widgetData.config);
|
||||
}
|
||||
this.dragHandler.destroyWidget(runtimeData.element);
|
||||
this.resizeHandler.destroyWidget(runtimeData.element);
|
||||
runtimeData.element.remove();
|
||||
this.widgets.delete(widgetId);
|
||||
}
|
||||
|
||||
// If widget is being moved to current tab, render it
|
||||
if (targetTabId === this.currentTabId) {
|
||||
const definition = this.registry.get(widgetData.type);
|
||||
if (definition) {
|
||||
this.renderWidget(widgetData, definition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto-save
|
||||
this.triggerAutoSave();
|
||||
|
||||
console.log(`[DashboardManager] Moved widget ${widgetId} from ${sourceTab.id} to ${targetTabId} at position (${widgetData.x}, ${widgetData.y})`);
|
||||
this.notifyChange('widgetMoved', { widgetId, sourceTabId: sourceTab.id, targetTabId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a widget's configuration
|
||||
* @param {string} widgetId - Widget ID
|
||||
@@ -851,25 +952,44 @@ export class DashboardManager {
|
||||
// Simple algorithm: try to place at top-left, move right, then down
|
||||
const tab = this.tabManager.getTab(this.currentTabId);
|
||||
const widgets = tab?.widgets || [];
|
||||
return this.findAvailablePositionInWidgets(size, widgets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find available position for widget in a specific widgets array
|
||||
* @param {Object} size - Widget size { w, h }
|
||||
* @param {Array<Object>} widgets - Array of existing widgets to check against
|
||||
* @returns {Object} Position { x, y }
|
||||
*/
|
||||
findAvailablePositionInWidgets(size, widgets) {
|
||||
console.log(`[DashboardManager] Finding available position for ${size.w}x${size.h} widget among ${widgets.length} existing widgets`);
|
||||
|
||||
// Try to place at top-left, move right, then down
|
||||
for (let y = 0; y < 20; y++) {
|
||||
for (let x = 0; x <= this.config.columns - size.w; x++) {
|
||||
const position = { x, y };
|
||||
const testWidget = { ...position, w: size.w, h: size.h };
|
||||
for (let x = 0; x <= this.gridEngine.columns - size.w; x++) {
|
||||
const testWidget = { x, y, w: size.w, h: size.h };
|
||||
|
||||
// Check if position is free
|
||||
const hasCollision = widgets.some(w =>
|
||||
this.gridEngine.detectCollision(testWidget, [w])
|
||||
);
|
||||
// Check if position overlaps with any existing widget
|
||||
const hasCollision = widgets.some(existingWidget => {
|
||||
const overlapsX = testWidget.x < existingWidget.x + existingWidget.w &&
|
||||
testWidget.x + testWidget.w > existingWidget.x;
|
||||
const overlapsY = testWidget.y < existingWidget.y + existingWidget.h &&
|
||||
testWidget.y + testWidget.h > existingWidget.y;
|
||||
return overlapsX && overlapsY;
|
||||
});
|
||||
|
||||
if (!hasCollision) {
|
||||
return position;
|
||||
console.log(`[DashboardManager] Found available position: (${x}, ${y})`);
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: place at bottom
|
||||
const maxY = Math.max(...widgets.map(w => w.y + w.h), 0);
|
||||
const maxY = widgets.length > 0
|
||||
? Math.max(...widgets.map(w => w.y + w.h))
|
||||
: 0;
|
||||
console.log(`[DashboardManager] No free space found, placing at bottom: (0, ${maxY})`);
|
||||
return { x: 0, y: maxY };
|
||||
}
|
||||
|
||||
@@ -1254,6 +1374,62 @@ export class DashboardManager {
|
||||
console.log(`[DashboardManager] Reset ${resetCount} widgets to default sizes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-layout widgets on current tab only
|
||||
* Sorts and arranges widgets on the current tab to maximize space usage
|
||||
*
|
||||
* @param {Object} options - Layout options
|
||||
* @param {boolean} [options.preserveOrder=true] - Maintain widget order during layout
|
||||
* @param {boolean} [options.resetSizes=true] - Reset widgets to default sizes before layout
|
||||
*/
|
||||
autoLayoutCurrentTab(options = {}) {
|
||||
console.log('[DashboardManager] Auto-layout current tab requested');
|
||||
|
||||
// Get current tab
|
||||
const currentTab = this.tabManager.getTab(this.currentTabId);
|
||||
if (!currentTab) {
|
||||
console.warn('[DashboardManager] No current tab found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentTab.widgets || currentTab.widgets.length === 0) {
|
||||
console.warn('[DashboardManager] Current tab has no widgets to layout');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DashboardManager] Laying out ${currentTab.widgets.length} widgets on tab "${currentTab.name}"`);
|
||||
|
||||
// Reset widget sizes to defaults (unless explicitly disabled)
|
||||
if (options.resetSizes !== false) {
|
||||
this.resetWidgetSizesToDefault(currentTab.widgets);
|
||||
}
|
||||
|
||||
// Sort widgets by category for better organization
|
||||
const sortedWidgets = this.sortWidgetsByCategory(currentTab.widgets);
|
||||
|
||||
// Update tab's widgets array with sorted order
|
||||
currentTab.widgets = sortedWidgets;
|
||||
|
||||
// Auto-layout widgets on the current tab
|
||||
this.gridEngine.autoLayout(currentTab.widgets, {
|
||||
preserveOrder: options.preserveOrder !== false
|
||||
});
|
||||
|
||||
// Re-render all widgets with new positions
|
||||
this.clearGrid();
|
||||
currentTab.widgets.forEach(widget => {
|
||||
const definition = this.registry.get(widget.type);
|
||||
if (definition) {
|
||||
this.renderWidget(widget, definition);
|
||||
}
|
||||
});
|
||||
|
||||
// Save layout
|
||||
this.triggerAutoSave();
|
||||
|
||||
console.log('[DashboardManager] Current tab layout complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-layout widgets on current tab to efficiently use all available space
|
||||
*
|
||||
|
||||
@@ -3,48 +3,67 @@
|
||||
<!-- Dashboard Header Controls -->
|
||||
<div class="rpg-dashboard-header">
|
||||
<div class="rpg-dashboard-header-left">
|
||||
<!-- Tab Navigation (will be populated by TabManager) -->
|
||||
<div id="rpg-dashboard-tabs" class="rpg-dashboard-tabs"></div>
|
||||
<!-- Tab Navigation Wrapper (with scroll controls) -->
|
||||
<div class="rpg-tab-nav-wrapper">
|
||||
<!-- Tabs container (will be populated by TabManager) -->
|
||||
<div id="rpg-dashboard-tabs" class="rpg-dashboard-tabs"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rpg-dashboard-header-right">
|
||||
<!-- Reset Layout Button -->
|
||||
<button id="rpg-dashboard-reset-layout" class="rpg-dashboard-btn rpg-reset-layout-btn" title="Reset to Default Layout">
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
<div class="rpg-dashboard-header-right" id="rpg-dashboard-header-right">
|
||||
<!-- Priority buttons (always visible) -->
|
||||
<button id="rpg-dashboard-lock-widgets" class="rpg-dashboard-btn rpg-lock-widgets-btn rpg-priority-btn" title="Unlock Widgets" aria-label="Lock/Unlock widgets">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
|
||||
<!-- Auto-Layout Button -->
|
||||
<button id="rpg-dashboard-auto-layout" class="rpg-dashboard-btn rpg-auto-layout-btn" title="Auto-Arrange Widgets">
|
||||
<i class="fa-solid fa-table-cells-large"></i>
|
||||
</button>
|
||||
|
||||
<!-- Lock/Unlock Button (always visible) -->
|
||||
<button id="rpg-dashboard-lock-widgets" class="rpg-dashboard-btn rpg-lock-widgets-btn" title="Lock Widgets">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
|
||||
<!-- Edit Widget Mode Toggle (hidden in edit mode) -->
|
||||
<button id="rpg-dashboard-edit-mode" class="rpg-dashboard-btn rpg-edit-mode-btn" title="Enter Edit Widget Mode">
|
||||
<button id="rpg-dashboard-edit-mode" class="rpg-dashboard-btn rpg-edit-mode-btn rpg-priority-btn" title="Enter Edit Widget Mode" aria-label="Edit mode">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</button>
|
||||
|
||||
<!-- Done Button (shown in edit mode) -->
|
||||
<button id="rpg-dashboard-done-edit" class="rpg-dashboard-btn rpg-done-edit-btn" style="display: none;" title="Exit Edit Widget Mode">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
<!-- Full mode buttons (hidden on overflow) -->
|
||||
<button id="rpg-dashboard-reset-layout" class="rpg-dashboard-btn rpg-reset-layout-btn rpg-overflow-btn" title="Reset to Default Layout" aria-label="Reset layout">
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
</button>
|
||||
|
||||
<!-- Add Widget Button (shown in edit mode, icon only) -->
|
||||
<button id="rpg-dashboard-add-widget" class="rpg-dashboard-btn rpg-add-widget-btn" style="display: none;" title="Add Widget">
|
||||
<button id="rpg-dashboard-auto-layout" class="rpg-dashboard-btn rpg-auto-layout-btn rpg-overflow-btn" title="Auto-Arrange All Widgets" aria-label="Auto-arrange all">
|
||||
<i class="fa-solid fa-table-cells-large"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-sort-tab" class="rpg-dashboard-btn rpg-sort-tab-btn rpg-overflow-btn" title="Sort Current Tab" aria-label="Sort tab">
|
||||
<i class="fa-solid fa-arrow-down-short-wide"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-add-widget" class="rpg-dashboard-btn rpg-add-widget-btn rpg-overflow-btn" style="display: none;" title="Add Widget" aria-label="Add widget">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
|
||||
<!-- Export/Import Layout Buttons (shown in edit mode) -->
|
||||
<button id="rpg-dashboard-export-layout" class="rpg-dashboard-btn rpg-export-btn" style="display: none;" title="Export Layout">
|
||||
<button id="rpg-dashboard-export-layout" class="rpg-dashboard-btn rpg-export-btn rpg-overflow-btn" style="display: none;" title="Export Layout" aria-label="Export layout">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
</button>
|
||||
<button id="rpg-dashboard-import-layout" class="rpg-dashboard-btn rpg-import-btn" style="display: none;" title="Import Layout">
|
||||
|
||||
<button id="rpg-dashboard-import-layout" class="rpg-dashboard-btn rpg-import-btn rpg-overflow-btn" style="display: none;" title="Import Layout" aria-label="Import layout">
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
</button>
|
||||
|
||||
<button id="rpg-dashboard-done-edit" class="rpg-dashboard-btn rpg-done-edit-btn rpg-overflow-btn" style="display: none;" title="Exit Edit Widget Mode" aria-label="Done editing">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
|
||||
<!-- Overflow Menu Button (⋮) - shown in overflow mode -->
|
||||
<button id="rpg-dashboard-overflow-menu" class="rpg-dashboard-btn rpg-overflow-menu-btn" style="display: none;" title="More Options" aria-label="More options" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-ellipsis-vertical"></i>
|
||||
</button>
|
||||
|
||||
<!-- Hamburger Menu Button (☰) - shown in compact mode -->
|
||||
<button id="rpg-dashboard-hamburger-menu" class="rpg-dashboard-btn rpg-hamburger-menu-btn" style="display: none;" title="Menu" aria-label="Menu" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu (populated dynamically) -->
|
||||
<div id="rpg-dashboard-dropdown-menu" class="rpg-dropdown-menu" role="menu" style="display: none;">
|
||||
<!-- Menu items added dynamically -->
|
||||
</div>
|
||||
|
||||
<input type="file" id="rpg-dashboard-import-file" accept=".json" style="display: none;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ export class DragDropHandler {
|
||||
constructor(gridEngine, options = {}) {
|
||||
this.gridEngine = gridEngine;
|
||||
this.editManager = options.editManager || null; // Reference to EditModeManager for lock state
|
||||
this.dashboardManager = options.dashboardManager || null; // Reference to DashboardManager for cross-tab moves
|
||||
this.options = {
|
||||
showGrid: true,
|
||||
showCollisions: true,
|
||||
@@ -40,6 +41,7 @@ export class DragDropHandler {
|
||||
this.gridOverlay = null;
|
||||
this.touchTimer = null;
|
||||
this.mouseDragPending = null; // Tracks potential mouse drag before threshold
|
||||
this.hoveredTab = null; // Currently hovered tab during drag
|
||||
|
||||
// Bound event handlers for cleanup
|
||||
this.boundMouseMove = this.onMouseMove.bind(this);
|
||||
@@ -305,6 +307,9 @@ export class DragDropHandler {
|
||||
if (this.gridOverlay) {
|
||||
this.highlightGridCells(snapped.x, snapped.y, widget.w, widget.h);
|
||||
}
|
||||
|
||||
// Check for tab hover (for cross-tab dragging)
|
||||
this.updateTabHover(clientX, clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,7 +360,20 @@ export class DragDropHandler {
|
||||
const dragHandle = element.querySelector('.drag-handle') || element;
|
||||
dragHandle.style.cursor = 'grab';
|
||||
|
||||
// Check for collision before committing
|
||||
// Check if dropped on a tab (cross-tab move)
|
||||
if (this.hoveredTab && this.dashboardManager) {
|
||||
const targetTabId = this.hoveredTab.dataset.tabId;
|
||||
console.log('[DragDrop] Dropped on tab:', targetTabId);
|
||||
|
||||
// Move widget to target tab
|
||||
this.dashboardManager.moveWidgetToTab(widget.id, targetTabId);
|
||||
|
||||
this.cleanup();
|
||||
console.log('[DragDrop] Widget moved to tab:', widget.id, '->', targetTabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal grid drop - check for collision before committing
|
||||
const otherWidgets = widgets.filter(w => w.id !== widget.id);
|
||||
const collision = this.gridEngine.detectCollision(widget, otherWidgets);
|
||||
|
||||
@@ -410,6 +428,9 @@ export class DragDropHandler {
|
||||
// Remove grid overlay
|
||||
this.hideGridOverlay();
|
||||
|
||||
// Clear tab hover highlight
|
||||
this.clearTabHover();
|
||||
|
||||
// Remove event listeners
|
||||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||||
@@ -524,6 +545,46 @@ export class DragDropHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tab hover state during drag
|
||||
* @param {number} clientX - Pointer X coordinate
|
||||
* @param {number} clientY - Pointer Y coordinate
|
||||
*/
|
||||
updateTabHover(clientX, clientY) {
|
||||
if (!this.dragState) return;
|
||||
|
||||
// Find tab element at pointer position
|
||||
const elementAtPoint = document.elementFromPoint(clientX, clientY);
|
||||
const tabElement = elementAtPoint?.closest('.rpg-dashboard-tab');
|
||||
|
||||
// Check if hover state changed
|
||||
if (tabElement !== this.hoveredTab) {
|
||||
// Clear previous highlight
|
||||
if (this.hoveredTab) {
|
||||
this.hoveredTab.classList.remove('drop-target');
|
||||
}
|
||||
|
||||
// Set new hover state
|
||||
this.hoveredTab = tabElement;
|
||||
|
||||
// Add highlight to new tab
|
||||
if (this.hoveredTab) {
|
||||
this.hoveredTab.classList.add('drop-target');
|
||||
console.log('[DragDrop] Hovering over tab:', this.hoveredTab.dataset.tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tab hover highlight
|
||||
*/
|
||||
clearTabHover() {
|
||||
if (this.hoveredTab) {
|
||||
this.hoveredTab.classList.remove('drop-target');
|
||||
this.hoveredTab = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current drag position has collisions
|
||||
* @param {Array<Object>} widgets - Array of other widgets
|
||||
|
||||
@@ -28,7 +28,7 @@ export class EditModeManager {
|
||||
this.onWidgetSettings = config.onWidgetSettings;
|
||||
|
||||
this.isEditMode = false;
|
||||
this.isLocked = false; // Lock state prevents dragging/resizing
|
||||
this.isLocked = true; // Start locked to prevent accidental widget moves
|
||||
this.originalLayout = null;
|
||||
this.gridOverlay = null;
|
||||
this.widgetLibrary = null;
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Header Overflow Manager
|
||||
*
|
||||
* Manages responsive button overflow behavior with three modes:
|
||||
* - Full Mode (>900px): All buttons visible
|
||||
* - Overflow Mode (500-900px): Priority buttons + "More" menu
|
||||
* - Compact Mode (<500px): Priority buttons + Hamburger menu
|
||||
*
|
||||
* Uses ResizeObserver for accurate width detection and smooth transitions.
|
||||
*/
|
||||
|
||||
export class HeaderOverflowManager {
|
||||
/**
|
||||
* @param {HTMLElement} headerContainer - The header right container
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(headerContainer, options = {}) {
|
||||
this.headerContainer = headerContainer;
|
||||
this.options = {
|
||||
fullModeWidth: 900, // px
|
||||
compactModeWidth: 500, // px
|
||||
debounceDelay: 100, // ms
|
||||
...options
|
||||
};
|
||||
|
||||
this.currentMode = 'full';
|
||||
this.menuOpen = false;
|
||||
this.resizeObserver = null;
|
||||
this.resizeTimeout = null;
|
||||
|
||||
// Element references
|
||||
this.priorityButtons = null;
|
||||
this.overflowButtons = null;
|
||||
this.overflowMenuBtn = null;
|
||||
this.hamburgerMenuBtn = null;
|
||||
this.dropdownMenu = null;
|
||||
|
||||
// Bound event handlers
|
||||
this.boundMenuToggle = this.toggleMenu.bind(this);
|
||||
this.boundCloseMenu = this.closeMenu.bind(this);
|
||||
this.boundKeyHandler = this.handleKeyDown.bind(this);
|
||||
this.boundClickOutside = this.handleClickOutside.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the overflow manager
|
||||
*/
|
||||
init() {
|
||||
console.log('[HeaderOverflowManager] Initializing...');
|
||||
|
||||
// Get element references
|
||||
this.priorityButtons = Array.from(this.headerContainer.querySelectorAll('.rpg-priority-btn'));
|
||||
this.overflowButtons = Array.from(this.headerContainer.querySelectorAll('.rpg-overflow-btn'));
|
||||
this.overflowMenuBtn = this.headerContainer.querySelector('#rpg-dashboard-overflow-menu');
|
||||
this.hamburgerMenuBtn = this.headerContainer.querySelector('#rpg-dashboard-hamburger-menu');
|
||||
this.dropdownMenu = this.headerContainer.querySelector('#rpg-dashboard-dropdown-menu');
|
||||
|
||||
if (!this.overflowMenuBtn || !this.hamburgerMenuBtn || !this.dropdownMenu) {
|
||||
console.error('[HeaderOverflowManager] Required elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up menu toggle listeners
|
||||
this.overflowMenuBtn.addEventListener('click', this.boundMenuToggle);
|
||||
this.hamburgerMenuBtn.addEventListener('click', this.boundMenuToggle);
|
||||
|
||||
// Set up resize observer
|
||||
this.setupResizeObserver();
|
||||
|
||||
// Initial mode detection
|
||||
this.updateMode();
|
||||
|
||||
console.log('[HeaderOverflowManager] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up ResizeObserver to monitor container width
|
||||
*/
|
||||
setupResizeObserver() {
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
// Debounce resize events
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
|
||||
this.resizeTimeout = setTimeout(() => {
|
||||
for (const entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
this.handleResize(width);
|
||||
}
|
||||
}, this.options.debounceDelay);
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(this.headerContainer);
|
||||
console.log('[HeaderOverflowManager] ResizeObserver set up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle container resize
|
||||
* @param {number} width - Container width in pixels
|
||||
*/
|
||||
handleResize(width) {
|
||||
let newMode = 'full';
|
||||
|
||||
if (width < this.options.compactModeWidth) {
|
||||
newMode = 'compact';
|
||||
} else if (width < this.options.fullModeWidth) {
|
||||
newMode = 'overflow';
|
||||
}
|
||||
|
||||
if (newMode !== this.currentMode) {
|
||||
console.log(`[HeaderOverflowManager] Mode change: ${this.currentMode} → ${newMode} (width: ${width}px)`);
|
||||
this.currentMode = newMode;
|
||||
this.updateMode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI based on current mode
|
||||
*/
|
||||
updateMode() {
|
||||
// Close menu if open
|
||||
if (this.menuOpen) {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
switch (this.currentMode) {
|
||||
case 'full':
|
||||
this.setFullMode();
|
||||
break;
|
||||
case 'overflow':
|
||||
this.setOverflowMode();
|
||||
break;
|
||||
case 'compact':
|
||||
this.setCompactMode();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Mode: Show all buttons
|
||||
*/
|
||||
setFullMode() {
|
||||
// Show all overflow buttons
|
||||
this.overflowButtons.forEach(btn => {
|
||||
if (btn.dataset.editMode === 'true' || !btn.hasAttribute('data-edit-mode')) {
|
||||
btn.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide menu buttons
|
||||
this.overflowMenuBtn.style.display = 'none';
|
||||
this.hamburgerMenuBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Overflow Mode: Priority buttons + "More" menu
|
||||
*/
|
||||
setOverflowMode() {
|
||||
// Hide overflow buttons (will be in dropdown)
|
||||
this.overflowButtons.forEach(btn => btn.style.display = 'none');
|
||||
|
||||
// Show overflow menu button
|
||||
this.overflowMenuBtn.style.display = '';
|
||||
this.hamburgerMenuBtn.style.display = 'none';
|
||||
|
||||
// Build menu with overflow buttons only
|
||||
this.buildDropdownMenu(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Mode: Priority buttons + Hamburger menu
|
||||
*/
|
||||
setCompactMode() {
|
||||
// Hide all overflow buttons
|
||||
this.overflowButtons.forEach(btn => btn.style.display = 'none');
|
||||
|
||||
// Show hamburger menu button
|
||||
this.overflowMenuBtn.style.display = 'none';
|
||||
this.hamburgerMenuBtn.style.display = '';
|
||||
|
||||
// Build menu with all buttons (including visible ones for context)
|
||||
this.buildDropdownMenu(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dropdown menu content
|
||||
* @param {boolean} includeAll - Include priority buttons in menu
|
||||
*/
|
||||
buildDropdownMenu(includeAll) {
|
||||
this.dropdownMenu.innerHTML = '';
|
||||
|
||||
const buttonsToShow = includeAll
|
||||
? [...this.overflowButtons]
|
||||
: this.overflowButtons;
|
||||
|
||||
// Filter visible buttons (considering edit mode)
|
||||
const visibleButtons = buttonsToShow.filter(btn => {
|
||||
const computedStyle = window.getComputedStyle(btn);
|
||||
return computedStyle.display !== 'none' || !btn.hasAttribute('data-edit-mode');
|
||||
});
|
||||
|
||||
if (visibleButtons.length === 0) {
|
||||
this.dropdownMenu.innerHTML = '<div class="rpg-dropdown-empty">No actions available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create menu items
|
||||
visibleButtons.forEach(btn => {
|
||||
const menuItem = this.createMenuItem(btn);
|
||||
this.dropdownMenu.appendChild(menuItem);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a menu item from a button
|
||||
* @param {HTMLElement} button - Button element to convert
|
||||
* @returns {HTMLElement} Menu item element
|
||||
*/
|
||||
createMenuItem(button) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'rpg-dropdown-item';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
|
||||
// Copy icon
|
||||
const icon = button.querySelector('i');
|
||||
if (icon) {
|
||||
item.innerHTML = icon.outerHTML;
|
||||
}
|
||||
|
||||
// Add label
|
||||
const label = document.createElement('span');
|
||||
label.textContent = button.getAttribute('title') || button.getAttribute('aria-label') || 'Action';
|
||||
item.appendChild(label);
|
||||
|
||||
// Copy click handler
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
button.click();
|
||||
this.closeMenu();
|
||||
});
|
||||
|
||||
// Handle edit mode visibility
|
||||
if (button.style.display === 'none' && button.dataset.editMode === 'true') {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle menu open/closed
|
||||
*/
|
||||
toggleMenu() {
|
||||
if (this.menuOpen) {
|
||||
this.closeMenu();
|
||||
} else {
|
||||
this.openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dropdown menu
|
||||
*/
|
||||
openMenu() {
|
||||
if (this.menuOpen) return;
|
||||
|
||||
this.menuOpen = true;
|
||||
this.dropdownMenu.style.display = 'block';
|
||||
|
||||
// Update aria-expanded
|
||||
const menuBtn = this.currentMode === 'compact' ? this.hamburgerMenuBtn : this.overflowMenuBtn;
|
||||
menuBtn.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Add close listeners
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.boundClickOutside);
|
||||
document.addEventListener('keydown', this.boundKeyHandler);
|
||||
}, 10);
|
||||
|
||||
// Focus first menu item
|
||||
const firstItem = this.dropdownMenu.querySelector('.rpg-dropdown-item');
|
||||
if (firstItem) {
|
||||
firstItem.focus();
|
||||
}
|
||||
|
||||
console.log('[HeaderOverflowManager] Menu opened');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dropdown menu
|
||||
*/
|
||||
closeMenu() {
|
||||
if (!this.menuOpen) return;
|
||||
|
||||
this.menuOpen = false;
|
||||
this.dropdownMenu.style.display = 'none';
|
||||
|
||||
// Update aria-expanded
|
||||
this.overflowMenuBtn.setAttribute('aria-expanded', 'false');
|
||||
this.hamburgerMenuBtn.setAttribute('aria-expanded', 'false');
|
||||
|
||||
// Remove close listeners
|
||||
document.removeEventListener('click', this.boundClickOutside);
|
||||
document.removeEventListener('keydown', this.boundKeyHandler);
|
||||
|
||||
console.log('[HeaderOverflowManager] Menu closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside menu
|
||||
* @param {MouseEvent} e - Click event
|
||||
*/
|
||||
handleClickOutside(e) {
|
||||
if (!this.dropdownMenu.contains(e.target) &&
|
||||
!this.overflowMenuBtn.contains(e.target) &&
|
||||
!this.hamburgerMenuBtn.contains(e.target)) {
|
||||
this.closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation
|
||||
* @param {KeyboardEvent} e - Keyboard event
|
||||
*/
|
||||
handleKeyDown(e) {
|
||||
if (!this.menuOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.closeMenu();
|
||||
// Return focus to menu button
|
||||
const menuBtn = this.currentMode === 'compact' ? this.hamburgerMenuBtn : this.overflowMenuBtn;
|
||||
menuBtn.focus();
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.focusNextItem();
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.focusPreviousItem();
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
this.focusFirstItem();
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
this.focusLastItem();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus management helpers
|
||||
*/
|
||||
focusNextItem() {
|
||||
const items = Array.from(this.dropdownMenu.querySelectorAll('.rpg-dropdown-item'));
|
||||
const currentIndex = items.indexOf(document.activeElement);
|
||||
const nextIndex = (currentIndex + 1) % items.length;
|
||||
items[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
focusPreviousItem() {
|
||||
const items = Array.from(this.dropdownMenu.querySelectorAll('.rpg-dropdown-item'));
|
||||
const currentIndex = items.indexOf(document.activeElement);
|
||||
const prevIndex = currentIndex <= 0 ? items.length - 1 : currentIndex - 1;
|
||||
items[prevIndex]?.focus();
|
||||
}
|
||||
|
||||
focusFirstItem() {
|
||||
const firstItem = this.dropdownMenu.querySelector('.rpg-dropdown-item');
|
||||
firstItem?.focus();
|
||||
}
|
||||
|
||||
focusLastItem() {
|
||||
const items = this.dropdownMenu.querySelectorAll('.rpg-dropdown-item');
|
||||
items[items.length - 1]?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh menu (called when edit mode changes)
|
||||
*/
|
||||
refresh() {
|
||||
console.log('[HeaderOverflowManager] Refreshing menu...');
|
||||
if (this.currentMode === 'overflow' || this.currentMode === 'compact') {
|
||||
this.buildDropdownMenu(this.currentMode === 'compact');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the overflow manager
|
||||
*/
|
||||
destroy() {
|
||||
console.log('[HeaderOverflowManager] Destroying...');
|
||||
|
||||
// Disconnect resize observer
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
this.overflowMenuBtn?.removeEventListener('click', this.boundMenuToggle);
|
||||
this.hamburgerMenuBtn?.removeEventListener('click', this.boundMenuToggle);
|
||||
document.removeEventListener('click', this.boundClickOutside);
|
||||
document.removeEventListener('keydown', this.boundKeyHandler);
|
||||
|
||||
// Close menu
|
||||
if (this.menuOpen) {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
console.log('[HeaderOverflowManager] Destroyed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Tab Scroll Manager
|
||||
*
|
||||
* Handles horizontal scrolling of dashboard tabs with:
|
||||
* - Left/Right navigation arrows
|
||||
* - Edge fade indicators
|
||||
* - Smooth scroll behavior
|
||||
* - Automatic arrow visibility
|
||||
*/
|
||||
|
||||
export class TabScrollManager {
|
||||
/**
|
||||
* @param {HTMLElement} tabContainer - The scrollable tabs container
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(tabContainer, options = {}) {
|
||||
this.tabContainer = tabContainer;
|
||||
this.options = {
|
||||
scrollAmount: 200, // px per click
|
||||
smoothScroll: true,
|
||||
showFadeIndicators: true,
|
||||
arrowHideDelay: 2000, // ms after scroll stops
|
||||
...options
|
||||
};
|
||||
|
||||
this.leftArrow = null;
|
||||
this.rightArrow = null;
|
||||
this.leftFade = null;
|
||||
this.rightFade = null;
|
||||
this.scrollTimeout = null;
|
||||
this.isScrolling = false;
|
||||
|
||||
this.boundScrollHandler = this.handleScroll.bind(this);
|
||||
this.boundResizeHandler = this.handleResize.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the scroll manager
|
||||
*/
|
||||
init() {
|
||||
console.log('[TabScrollManager] Initializing...');
|
||||
|
||||
// Create arrow buttons
|
||||
this.createArrows();
|
||||
|
||||
// Create fade indicators if enabled
|
||||
if (this.options.showFadeIndicators) {
|
||||
this.createFadeIndicators();
|
||||
}
|
||||
|
||||
// Set up event listeners
|
||||
this.tabContainer.addEventListener('scroll', this.boundScrollHandler);
|
||||
window.addEventListener('resize', this.boundResizeHandler);
|
||||
|
||||
// Initial state update
|
||||
this.updateScrollState();
|
||||
|
||||
console.log('[TabScrollManager] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create left and right arrow buttons
|
||||
*/
|
||||
createArrows() {
|
||||
const wrapper = this.tabContainer.parentElement;
|
||||
|
||||
// Left arrow
|
||||
this.leftArrow = document.createElement('button');
|
||||
this.leftArrow.className = 'rpg-tab-nav-arrow rpg-tab-nav-left';
|
||||
this.leftArrow.innerHTML = '<i class="fa-solid fa-chevron-left"></i>';
|
||||
this.leftArrow.setAttribute('aria-label', 'Scroll tabs left');
|
||||
this.leftArrow.addEventListener('click', () => this.scrollLeft());
|
||||
|
||||
// Right arrow
|
||||
this.rightArrow = document.createElement('button');
|
||||
this.rightArrow.className = 'rpg-tab-nav-arrow rpg-tab-nav-right';
|
||||
this.rightArrow.innerHTML = '<i class="fa-solid fa-chevron-right"></i>';
|
||||
this.rightArrow.setAttribute('aria-label', 'Scroll tabs right');
|
||||
this.rightArrow.addEventListener('click', () => this.scrollRight());
|
||||
|
||||
// Insert arrows
|
||||
wrapper.insertBefore(this.leftArrow, this.tabContainer);
|
||||
wrapper.appendChild(this.rightArrow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fade indicator overlays
|
||||
*/
|
||||
createFadeIndicators() {
|
||||
const wrapper = this.tabContainer.parentElement;
|
||||
|
||||
// Left fade
|
||||
this.leftFade = document.createElement('div');
|
||||
this.leftFade.className = 'rpg-tab-fade rpg-tab-fade-left';
|
||||
|
||||
// Right fade
|
||||
this.rightFade = document.createElement('div');
|
||||
this.rightFade.className = 'rpg-tab-fade rpg-tab-fade-right';
|
||||
|
||||
// Insert fades
|
||||
wrapper.insertBefore(this.leftFade, this.tabContainer);
|
||||
wrapper.appendChild(this.rightFade);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll tabs to the left
|
||||
*/
|
||||
scrollLeft() {
|
||||
const scrollAmount = this.options.scrollAmount;
|
||||
const targetScroll = Math.max(0, this.tabContainer.scrollLeft - scrollAmount);
|
||||
|
||||
if (this.options.smoothScroll) {
|
||||
this.tabContainer.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else {
|
||||
this.tabContainer.scrollLeft = targetScroll;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll tabs to the right
|
||||
*/
|
||||
scrollRight() {
|
||||
const scrollAmount = this.options.scrollAmount;
|
||||
const maxScroll = this.tabContainer.scrollWidth - this.tabContainer.clientWidth;
|
||||
const targetScroll = Math.min(maxScroll, this.tabContainer.scrollLeft + scrollAmount);
|
||||
|
||||
if (this.options.smoothScroll) {
|
||||
this.tabContainer.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else {
|
||||
this.tabContainer.scrollLeft = targetScroll;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll events
|
||||
*/
|
||||
handleScroll() {
|
||||
this.isScrolling = true;
|
||||
|
||||
// Update arrow and fade visibility
|
||||
this.updateScrollState();
|
||||
|
||||
// Clear previous timeout
|
||||
if (this.scrollTimeout) {
|
||||
clearTimeout(this.scrollTimeout);
|
||||
}
|
||||
|
||||
// Hide arrows after scroll stops (optional)
|
||||
if (this.options.arrowHideDelay > 0) {
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
this.isScrolling = false;
|
||||
this.updateScrollState();
|
||||
}, this.options.arrowHideDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle window resize
|
||||
*/
|
||||
handleResize() {
|
||||
this.updateScrollState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update arrow and fade visibility based on scroll position
|
||||
*/
|
||||
updateScrollState() {
|
||||
const scrollLeft = this.tabContainer.scrollLeft;
|
||||
const scrollWidth = this.tabContainer.scrollWidth;
|
||||
const clientWidth = this.tabContainer.clientWidth;
|
||||
const maxScroll = scrollWidth - clientWidth;
|
||||
|
||||
const isScrollable = scrollWidth > clientWidth;
|
||||
const isAtStart = scrollLeft <= 1; // Small threshold for floating point
|
||||
const isAtEnd = scrollLeft >= maxScroll - 1;
|
||||
|
||||
// Show/hide left arrow
|
||||
if (this.leftArrow) {
|
||||
if (isScrollable && !isAtStart) {
|
||||
this.leftArrow.classList.add('visible');
|
||||
} else {
|
||||
this.leftArrow.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide right arrow
|
||||
if (this.rightArrow) {
|
||||
if (isScrollable && !isAtEnd) {
|
||||
this.rightArrow.classList.add('visible');
|
||||
} else {
|
||||
this.rightArrow.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide fade indicators
|
||||
if (this.leftFade) {
|
||||
if (isScrollable && !isAtStart) {
|
||||
this.leftFade.classList.add('visible');
|
||||
} else {
|
||||
this.leftFade.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.rightFade) {
|
||||
if (isScrollable && !isAtEnd) {
|
||||
this.rightFade.classList.add('visible');
|
||||
} else {
|
||||
this.rightFade.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll a specific tab into view
|
||||
* @param {HTMLElement} tabElement - Tab element to scroll to
|
||||
*/
|
||||
scrollToTab(tabElement) {
|
||||
if (!tabElement) return;
|
||||
|
||||
tabElement.scrollIntoView({
|
||||
behavior: this.options.smoothScroll ? 'smooth' : 'auto',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the scroll manager
|
||||
*/
|
||||
destroy() {
|
||||
console.log('[TabScrollManager] Destroying...');
|
||||
|
||||
// Remove event listeners
|
||||
this.tabContainer.removeEventListener('scroll', this.boundScrollHandler);
|
||||
window.removeEventListener('resize', this.boundResizeHandler);
|
||||
|
||||
// Clear timeout
|
||||
if (this.scrollTimeout) {
|
||||
clearTimeout(this.scrollTimeout);
|
||||
}
|
||||
|
||||
// Remove arrows
|
||||
if (this.leftArrow) this.leftArrow.remove();
|
||||
if (this.rightArrow) this.rightArrow.remove();
|
||||
|
||||
// Remove fade indicators
|
||||
if (this.leftFade) this.leftFade.remove();
|
||||
if (this.rightFade) this.rightFade.remove();
|
||||
|
||||
console.log('[TabScrollManager] Destroyed');
|
||||
}
|
||||
}
|
||||
@@ -1052,6 +1052,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible; /* Allow horizontal overflow for dropdown menu */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -1062,6 +1063,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible; /* Prevent clipping of dropdown menu */
|
||||
}
|
||||
|
||||
.rpg-dashboard-header-left,
|
||||
@@ -1069,16 +1071,58 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Position context for dropdown menu */
|
||||
.rpg-dashboard-header-right {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure buttons stay compact on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.rpg-dashboard-header-right {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.rpg-dashboard-btn {
|
||||
padding: 0.4rem;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab Navigation Wrapper */
|
||||
.rpg-tab-nav-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Scrollable Tabs Container */
|
||||
.rpg-dashboard-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Hide scrollbar */
|
||||
.rpg-dashboard-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Individual Tab */
|
||||
.rpg-dashboard-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1094,6 +1138,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-dashboard-tab:hover {
|
||||
@@ -1110,6 +1156,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
||||
|
||||
.rpg-tab-icon {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rpg-tab-name {
|
||||
@@ -1123,6 +1170,109 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
/* Tab Navigation Arrows */
|
||||
.rpg-tab-nav-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
background: var(--SmartThemeBlurTintColor);
|
||||
color: var(--SmartThemeBodyColor);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rpg-tab-nav-arrow.visible {
|
||||
display: flex;
|
||||
opacity: 0.8;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.rpg-tab-nav-arrow:hover {
|
||||
opacity: 1;
|
||||
background: var(--SmartThemeQuoteColor);
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
.rpg-tab-nav-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.rpg-tab-nav-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Fade Indicators */
|
||||
.rpg-tab-fade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3rem;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.rpg-tab-fade.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rpg-tab-fade-left {
|
||||
left: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--SmartThemeBlurTintColor) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.rpg-tab-fade-right {
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
var(--SmartThemeBlurTintColor) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Responsive Tab Sizing */
|
||||
@media (max-width: 768px) {
|
||||
.rpg-dashboard-tab {
|
||||
padding: 0.4rem;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.rpg-tab-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.rpg-tab-name {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.rpg-dashboard-tab {
|
||||
padding: 0.3rem;
|
||||
min-width: 1.8rem;
|
||||
}
|
||||
|
||||
.rpg-tab-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.rpg-dashboard-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1148,6 +1298,311 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Header Dropdown Menu
|
||||
======================================== */
|
||||
|
||||
/* Dropdown Menu Container */
|
||||
.rpg-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
background: var(--SmartThemeBlurTintColor);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10003; /* Above modals (10000) and mobile toggle (10002) */
|
||||
overflow: hidden;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu Item */
|
||||
.rpg-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rpg-dropdown-item:hover,
|
||||
.rpg-dropdown-item:focus {
|
||||
background: var(--SmartThemeQuoteColor);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rpg-dropdown-item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rpg-dropdown-item i {
|
||||
width: 1.2rem;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rpg-dropdown-item span {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.rpg-dropdown-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
opacity: 0.6;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Menu button active state */
|
||||
.rpg-dashboard-btn[aria-expanded="true"] {
|
||||
background: var(--SmartThemeQuoteColor);
|
||||
border-color: var(--rpg-highlight);
|
||||
}
|
||||
|
||||
/* Priority buttons always visible */
|
||||
.rpg-priority-btn {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
/* Overflow/Hamburger menu buttons */
|
||||
.rpg-overflow-menu-btn,
|
||||
.rpg-hamburger-menu-btn {
|
||||
order: 1000;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for dropdown */
|
||||
@media (max-width: 768px) {
|
||||
.rpg-dropdown-menu {
|
||||
min-width: 180px;
|
||||
right: -0.5rem;
|
||||
}
|
||||
|
||||
.rpg-dropdown-item {
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rpg-dropdown-item i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.rpg-dropdown-menu {
|
||||
min-width: 160px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
|
||||
.rpg-dropdown-item {
|
||||
padding: 0.6rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Dashboard Modals
|
||||
======================================== */
|
||||
|
||||
/* Modal Overlay */
|
||||
.rpg-modal {
|
||||
display: none; /* Hidden by default, shown with display: flex */
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Modal Content Container */
|
||||
.rpg-modal-content {
|
||||
background: var(--SmartThemeBlurTintColor);
|
||||
border: 2px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Modal Header */
|
||||
.rpg-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--SmartThemeBorderColor);
|
||||
}
|
||||
|
||||
.rpg-modal-header h3 {
|
||||
margin: 0;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rpg-modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.rpg-modal-close:hover {
|
||||
color: var(--rpg-highlight);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Modal Body */
|
||||
.rpg-modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Modal Footer */
|
||||
.rpg-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--SmartThemeBorderColor);
|
||||
}
|
||||
|
||||
/* Widget Grid in Modal */
|
||||
.rpg-widget-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Widget Card */
|
||||
.rpg-widget-card {
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 8px;
|
||||
background: var(--SmartThemeQuoteColor);
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rpg-widget-card:hover {
|
||||
border-color: var(--rpg-highlight);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.rpg-widget-card-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.rpg-widget-card-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
}
|
||||
|
||||
.rpg-widget-card-description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--SmartThemeFastUISliderColColor);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rpg-widget-card-add {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--rpg-highlight);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rpg-widget-card-add:hover {
|
||||
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.8);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Modal Buttons */
|
||||
.rpg-btn-primary,
|
||||
.rpg-btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rpg-btn-primary {
|
||||
background: var(--rpg-highlight);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.rpg-btn-primary:hover {
|
||||
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.8);
|
||||
}
|
||||
|
||||
.rpg-btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
color: var(--SmartThemeBodyColor);
|
||||
}
|
||||
|
||||
.rpg-btn-secondary:hover {
|
||||
background: var(--SmartThemeQuoteColor);
|
||||
}
|
||||
|
||||
/* Tab Drop Zone Highlight */
|
||||
.rpg-dashboard-tab.drop-target {
|
||||
background: var(--rpg-highlight) !important;
|
||||
color: white !important;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 12px var(--rpg-highlight);
|
||||
}
|
||||
|
||||
.rpg-dashboard-grid {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user