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:
Lucas 'Paperboy' Rose-Winters
2025-10-27 14:48:38 +11:00
parent 45c5853dcb
commit f566ad1d93
8 changed files with 1474 additions and 41 deletions
@@ -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);
}
}
});
}
+187 -11
View File
@@ -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
*
+44 -25
View File
@@ -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) -->
<!-- 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>
+62 -1
View File
@@ -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
+1 -1
View File
@@ -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');
}
}
+258
View File
@@ -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');
}
}
+457 -2
View File
@@ -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%;