diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js index d69aafe..6c87690 100644 --- a/src/systems/dashboard/dashboardIntegration.js +++ b/src/systems/dashboard/dashboardIntegration.js @@ -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); + } } }); } diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js index a915a36..abe1153 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -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} 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 * diff --git a/src/systems/dashboard/dashboardTemplate.html b/src/systems/dashboard/dashboardTemplate.html index 6a8f67f..906778d 100644 --- a/src/systems/dashboard/dashboardTemplate.html +++ b/src/systems/dashboard/dashboardTemplate.html @@ -3,48 +3,67 @@
- -
+ +
+ +
+
-
- - - - - - - - - - - - - - + + + + - - - + + + + + + + + + + + +
diff --git a/src/systems/dashboard/dragDrop.js b/src/systems/dashboard/dragDrop.js index 2a3a9ab..2ba4704 100644 --- a/src/systems/dashboard/dragDrop.js +++ b/src/systems/dashboard/dragDrop.js @@ -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} widgets - Array of other widgets diff --git a/src/systems/dashboard/editModeManager.js b/src/systems/dashboard/editModeManager.js index b294e70..e4cf99e 100644 --- a/src/systems/dashboard/editModeManager.js +++ b/src/systems/dashboard/editModeManager.js @@ -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; diff --git a/src/systems/dashboard/headerOverflowManager.js b/src/systems/dashboard/headerOverflowManager.js new file mode 100644 index 0000000..47628f0 --- /dev/null +++ b/src/systems/dashboard/headerOverflowManager.js @@ -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 = '
No actions available
'; + 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'); + } +} diff --git a/src/systems/dashboard/tabScrollManager.js b/src/systems/dashboard/tabScrollManager.js new file mode 100644 index 0000000..21369a8 --- /dev/null +++ b/src/systems/dashboard/tabScrollManager.js @@ -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 = ''; + 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 = ''; + 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'); + } +} diff --git a/style.css b/style.css index 5cce04f..530b0d4 100644 --- a/style.css +++ b/style.css @@ -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%;