refactor(dashboard): move controls to overlays and fix mobile/positioning issues

BREAKING CHANGES:
- Resize handles and edit controls now render in separate overlay containers
- This prevents widget overflow and scrollbar issues

Major Changes:

Overlay System Refactor:
- Create overlay containers for resize handles and edit controls
- Append handles/controls to overlays instead of widget DOM
- Fix position calculations using offsetLeft/Top instead of getBoundingClientRect
- Add position sync after resize/drag/reposition operations
- Add cleanup methods when switching tabs

Mobile Fixes:
- Add pointer/touch event support for Add Widget button
- Fix file upload trigger for iOS/Android compatibility
- Move modals to document.body with flex centering
- Fix modal button font-sizes (rem-based instead of vw)
- Add mobile-responsive styling for widget dialog

Bug Fixes:
- Fix getActiveTabId() calls (use activeTabId property instead)
- Fix file input disabled state (exclude type="file" from edit mode disable)
- Fix Add Widget modal registry.getAll() destructuring (objects not arrays)
- Fix edit/delete button hover (keep visible when hovering buttons)
- Fix reset layout to restore deleted widgets (regenerate default layout)

UI Improvements:
- No more scrollbars from resize handles
- Consistent button visibility in edit mode
- Touch-friendly button sizes on mobile (44px min-height)
- Single-column widget grid on mobile
- Proper z-index stacking for overlays

Files Changed:
- dashboardManager.js: Overlay container management, sync methods
- resizeHandler.js: Append to overlay, update positioning
- editModeManager.js: Append to overlay, hover behavior, sync/cleanup
- dashboardIntegration.js: Mobile touch events, file upload, modal fixes
- dashboardTemplate.html: File input accessibility
- style.css: Modal button font-sizes, mobile optimizations
- RESIZE_HANDLES_INVESTIGATION.md: Technical investigation documentation
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-29 19:07:01 +11:00
parent ddb2f8c222
commit 9fbc35dbd9
7 changed files with 651 additions and 28 deletions
+68 -11
View File
@@ -306,14 +306,21 @@ function setupDashboardEventListeners(dependencies) {
});
}
// Add widget button
// Add widget button - supports both desktop click and mobile touch
const addWidgetBtn = document.querySelector('#rpg-dashboard-add-widget');
if (addWidgetBtn) {
addWidgetBtn.addEventListener('click', () => {
// Use pointerdown for universal desktop/mobile support
const openAddWidget = (e) => {
e.preventDefault();
e.stopPropagation();
if (dashboardManager) {
showAddWidgetDialog(dashboardManager);
}
});
};
// Listen to both click (desktop) and pointerdown (mobile) for maximum compatibility
addWidgetBtn.addEventListener('click', openAddWidget);
addWidgetBtn.addEventListener('pointerdown', openAddWidget, { once: true });
}
// Export layout button
@@ -326,22 +333,51 @@ function setupDashboardEventListeners(dependencies) {
});
}
// Import layout button
// Import layout button - trigger file input on click
const importBtn = document.querySelector('#rpg-dashboard-import-layout');
const importFile = document.querySelector('#rpg-dashboard-import-file');
if (importBtn && importFile) {
importBtn.addEventListener('click', () => {
importFile.click();
console.log('[RPG Companion] Import button and file input initialized');
// Trigger file picker on button click
importBtn.addEventListener('click', (e) => {
console.log('[RPG Companion] Import button clicked, triggering file picker');
console.log('[RPG Companion] File input element:', importFile);
console.log('[RPG Companion] File input visible:', importFile.offsetParent !== null);
try {
// Direct click works on desktop and mobile when input is properly positioned
importFile.click();
console.log('[RPG Companion] File input click() called successfully');
} catch (err) {
console.error('[RPG Companion] Error triggering file input:', err);
}
});
// Handle file selection
importFile.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file && dashboardManager) {
dashboardManager.importLayout(file);
console.log('[RPG Companion] File input change event fired');
console.log('[RPG Companion] Selected file:', file);
if (file) {
if (dashboardManager) {
console.log('[RPG Companion] Importing layout from:', file.name);
dashboardManager.importLayout(file);
} else {
console.error('[RPG Companion] Dashboard manager not available');
}
importFile.value = ''; // Reset file input
} else {
console.warn('[RPG Companion] No file selected');
}
});
} else {
console.error('[RPG Companion] Import button or file input not found!', {
importBtn,
importFile
});
}
}
@@ -354,7 +390,8 @@ function showAddWidgetDialog(manager) {
const widgets = registry.getAll();
// Create widget cards HTML
const widgetCardsHtml = widgets.map(([type, definition]) => `
// Note: registry.getAll() returns [{type, definition}, ...] not [[type, definition], ...]
const widgetCardsHtml = widgets.map(({type, definition}) => `
<div class="rpg-widget-card" data-widget-type="${type}">
<div class="rpg-widget-card-icon">${definition.icon}</div>
<div class="rpg-widget-card-name">${definition.name}</div>
@@ -372,6 +409,22 @@ function showAddWidgetDialog(manager) {
return;
}
// CRITICAL: Move modal to document.body on first use to escape panel constraints
// The panel has transform in its transition which creates a containing block,
// constraining position:fixed children to the panel instead of viewport
if (modal.parentElement?.id !== 'document-body-modals') {
// Create container for modals at body level (only once)
let bodyModalsContainer = document.getElementById('document-body-modals');
if (!bodyModalsContainer) {
bodyModalsContainer = document.createElement('div');
bodyModalsContainer.id = 'document-body-modals';
bodyModalsContainer.style.cssText = 'position: fixed; inset: 0; pointer-events: none; z-index: 10000; display: flex; align-items: center; justify-content: center;';
document.body.appendChild(bodyModalsContainer);
}
bodyModalsContainer.appendChild(modal);
console.log('[RPG Companion] Moved Add Widget modal to document.body for proper viewport positioning');
}
const widgetSelector = modal.querySelector('#rpg-widget-selector');
if (widgetSelector) {
widgetSelector.innerHTML = widgetCardsHtml;
@@ -380,7 +433,8 @@ function showAddWidgetDialog(manager) {
widgetSelector.querySelectorAll('.rpg-widget-card-add').forEach(btn => {
btn.addEventListener('click', () => {
const widgetType = btn.dataset.widgetType;
const activeTab = manager.tabManager.getActiveTabId();
// Use activeTabId property instead of getActiveTabId() method
const activeTab = manager.tabManager.activeTabId;
manager.addWidget(widgetType, activeTab);
hideModal('rpg-add-widget-modal');
@@ -388,7 +442,9 @@ function showAddWidgetDialog(manager) {
});
}
// Show modal with proper pointer events (parent has pointer-events: none)
modal.style.display = 'flex';
modal.style.pointerEvents = 'auto';
// Set up modal close handlers
modal.querySelectorAll('[data-close="add-widget"]').forEach(btn => {
@@ -424,7 +480,8 @@ export function createDefaultLayout(manager) {
console.log('[RPG Companion] Creating default dashboard layout with modular widgets...');
const mainTab = manager.tabManager.getActiveTabId();
// Use activeTabId property instead of getActiveTabId() method
const mainTab = manager.tabManager.activeTabId;
// Add modular user widgets
// Row 0: User Info (avatar, name, level) - full width
+61 -6
View File
@@ -20,6 +20,7 @@ import { DragDropHandler } from './dragDrop.js';
import { ResizeHandler } from './resizeHandler.js';
import { EditModeManager } from './editModeManager.js';
import { LayoutPersistence } from './layoutPersistence.js';
import { generateDefaultDashboard } from './defaultLayout.js';
/**
* @typedef {Object} DashboardConfig
@@ -86,6 +87,8 @@ export class DashboardManager {
// Container elements
this.gridContainer = null;
this.tabContainer = null;
this.resizeHandlesOverlay = null;
this.editControlsOverlay = null;
this.changeListeners = new Set();
@@ -159,6 +162,7 @@ export class DashboardManager {
// Initialize Edit Mode Manager first (needed by drag/resize handlers)
this.editManager = new EditModeManager({
container: this.container,
editControlsOverlay: this.editControlsOverlay,
onSave: () => this.handleEditSave(),
onCancel: (originalLayout) => this.handleEditCancel(originalLayout),
onWidgetAdd: (type) => this.addWidget(type),
@@ -174,13 +178,14 @@ export class DashboardManager {
dashboardManager: this
});
// Initialize Resize Handler (with editManager reference)
// Initialize Resize Handler (with editManager and overlay references)
this.resizeHandler = new ResizeHandler(this.gridEngine, {
minWidth: 1,
minHeight: 2,
maxWidth: 4, // Max 4 columns (will be clamped to actual column count)
maxHeight: 10,
editManager: this.editManager
editManager: this.editManager,
resizeHandlesOverlay: this.resizeHandlesOverlay
});
// Initialize Layout Persistence
@@ -240,7 +245,21 @@ export class DashboardManager {
this.container.appendChild(this.gridContainer);
}
console.log('[DashboardManager] Container structure ready');
// Create overlay containers for resize handles and edit controls
// These are positioned outside the widget DOM to prevent overflow/scrollbar issues
this.resizeHandlesOverlay = document.createElement('div');
this.resizeHandlesOverlay.id = 'rpg-resize-handles-overlay';
this.resizeHandlesOverlay.className = 'rpg-overlay-container';
this.resizeHandlesOverlay.style.cssText = 'position: absolute; inset: 0; pointer-events: none; z-index: 9999;';
this.gridContainer.appendChild(this.resizeHandlesOverlay);
this.editControlsOverlay = document.createElement('div');
this.editControlsOverlay.id = 'rpg-edit-controls-overlay';
this.editControlsOverlay.className = 'rpg-overlay-container';
this.editControlsOverlay.style.cssText = 'position: absolute; inset: 0; pointer-events: none; z-index: 10000;';
this.gridContainer.appendChild(this.editControlsOverlay);
console.log('[DashboardManager] Container structure ready (including overlays)');
}
/**
@@ -735,8 +754,8 @@ export class DashboardManager {
el.contentEditable = 'false';
});
// Also disable input fields
const inputElements = element.querySelectorAll('input, textarea');
// Also disable input fields (except file inputs which should remain functional)
const inputElements = element.querySelectorAll('input:not([type="file"]), textarea');
inputElements.forEach(el => {
el.dataset.wasEnabled = el.disabled ? 'false' : 'true';
el.disabled = true;
@@ -755,6 +774,32 @@ export class DashboardManager {
element.style.top = pos.top;
element.style.width = pos.width;
element.style.height = pos.height;
// Update overlay positions (resize handles and edit controls) to match new widget position
this.syncOverlaysForWidget(element, widget.id);
}
/**
* Sync overlay elements (handles and controls) for a specific widget
* @param {HTMLElement} element - Widget element
* @param {string} widgetId - Widget ID
*/
syncOverlaysForWidget(element, widgetId) {
// Update resize handles position
if (this.resizeHandler) {
const handlerData = this.resizeHandler.resizeHandlers.get(element);
if (handlerData && handlerData.handles) {
this.resizeHandler.updateHandlePosition(handlerData.handles, element);
}
}
// Update edit controls position
if (this.editManager && this.editManager.isEditMode) {
const controlData = this.editManager.widgetControlsMap.get(widgetId);
if (controlData && controlData.controls) {
this.editManager.updateControlPosition(controlData.controls, element);
}
}
}
/**
@@ -1098,6 +1143,11 @@ export class DashboardManager {
* Clear all widgets from grid
*/
clearGrid() {
// Clean up edit controls overlay first
if (this.editManager) {
this.editManager.removeAllControls();
}
// Destroy all widgets
this.widgets.forEach((widgetData, widgetId) => {
const definition = this.registry.get(widgetData.widget.type);
@@ -1351,8 +1401,13 @@ export class DashboardManager {
* Reset to default layout
*/
async resetLayout() {
// Regenerate fresh default layout to ensure all original widgets are restored
// This ensures deleted widgets come back on reset
console.log('[DashboardManager] Regenerating fresh default layout...');
this.defaultLayout = generateDefaultDashboard();
if (!this.defaultLayout) {
console.warn('[DashboardManager] No default layout defined');
console.warn('[DashboardManager] Failed to generate default layout');
return;
}
+3 -1
View File
@@ -64,7 +64,9 @@
<!-- Menu items added dynamically -->
</div>
<input type="file" id="rpg-dashboard-import-file" accept=".json" style="display: none;" />
<!-- File input: visually hidden but accessible for mobile compatibility -->
<!-- Use 1px size for better browser compatibility while keeping hidden -->
<input type="file" id="rpg-dashboard-import-file" accept=".json" style="position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; z-index: -1; pointer-events: auto;" />
</div>
</div>
+124 -9
View File
@@ -23,6 +23,7 @@ export class EditModeManager {
*/
constructor(config) {
this.container = config.container;
this.editControlsOverlay = config.editControlsOverlay || null; // Overlay container for edit controls
this.onSave = config.onSave;
this.onCancel = config.onCancel;
this.onWidgetAdd = config.onWidgetAdd;
@@ -69,6 +70,9 @@ export class EditModeManager {
// Add edit class to container
this.container.classList.add('edit-mode');
// Add controls to all currently rendered widgets
this.syncAllControls();
this.notifyChange('editModeEntered');
console.log('[EditModeManager] Entered edit mode');
}
@@ -180,8 +184,8 @@ export class EditModeManager {
element.contentEditable = 'false';
});
// Also disable input fields
const inputElements = this.container.querySelectorAll('input, textarea');
// Also disable input fields (except file inputs which should remain functional)
const inputElements = this.container.querySelectorAll('input:not([type="file"]), textarea');
inputElements.forEach(element => {
element.dataset.wasEnabled = element.disabled ? 'false' : 'true';
element.disabled = true;
@@ -356,20 +360,86 @@ export class EditModeManager {
controls.appendChild(settingsBtn);
controls.appendChild(deleteBtn);
element.appendChild(controls);
// Store reference to widget element for positioning
controls.dataset.widgetId = widgetId;
// Show controls on hover
// Append to overlay instead of widget to prevent overflow/scrollbar issues
if (this.editControlsOverlay) {
this.editControlsOverlay.appendChild(controls);
// Position controls to match widget bounds
this.updateControlPosition(controls, element);
} else {
// Fallback to old behavior if overlay not available
element.appendChild(controls);
}
// Show controls on hover - keep visible when hovering controls themselves
let isHoveringWidget = false;
let isHoveringControls = false;
let hideTimeout = null;
const checkAndHideControls = () => {
// Clear any existing timeout
if (hideTimeout) {
clearTimeout(hideTimeout);
}
// Add small delay to allow mouse to move between widget and controls
hideTimeout = setTimeout(() => {
if (!isHoveringWidget && !isHoveringControls) {
controls.style.opacity = '0';
}
}, 100);
};
// Widget hover
element.addEventListener('mouseenter', () => {
isHoveringWidget = true;
if (this.isEditMode) {
controls.style.opacity = '1';
}
});
element.addEventListener('mouseleave', () => {
controls.style.opacity = '0';
isHoveringWidget = false;
checkAndHideControls();
});
this.widgetControlsMap.set(widgetId, controls);
// Controls hover - keep visible when hovering the buttons
controls.addEventListener('mouseenter', () => {
isHoveringControls = true;
controls.style.opacity = '1';
});
controls.addEventListener('mouseleave', () => {
isHoveringControls = false;
checkAndHideControls();
});
this.widgetControlsMap.set(widgetId, { controls, element });
}
/**
* Update control position to match widget bounds
* @param {HTMLElement} controls - Edit controls container
* @param {HTMLElement} element - Widget element
*/
updateControlPosition(controls, element) {
if (!controls || !element) return;
const overlay = this.editControlsOverlay;
if (!overlay) return;
// Use offset properties for parent-relative positioning
// Both widget and overlay are children of the same grid container
const widgetLeft = element.offsetLeft;
const widgetTop = element.offsetTop;
const widgetWidth = element.offsetWidth;
// Position controls at top-right of widget (4px from top, 4px from right)
controls.style.left = `${widgetLeft + widgetWidth - 60}px`; // 60px approximate width of controls
controls.style.top = `${widgetTop + 4}px`;
controls.style.pointerEvents = 'auto'; // Ensure controls are clickable
}
/**
@@ -377,13 +447,58 @@ export class EditModeManager {
* @param {string} widgetId - Widget ID
*/
removeWidgetControls(widgetId) {
const controls = this.widgetControlsMap.get(widgetId);
if (controls) {
controls.remove();
const data = this.widgetControlsMap.get(widgetId);
if (data) {
if (data.controls) {
data.controls.remove();
}
this.widgetControlsMap.delete(widgetId);
}
}
/**
* Sync controls for all currently rendered widgets
* Adds controls to widgets that don't have them yet
*/
syncAllControls() {
// Find all widget elements in the grid
const gridContainer = this.container.querySelector('#rpg-dashboard-grid');
if (!gridContainer) return;
const widgets = gridContainer.querySelectorAll('.rpg-widget');
widgets.forEach(widgetElement => {
const widgetId = widgetElement.dataset.widgetId;
if (!widgetId) return;
// Add controls if they don't exist yet
if (!this.widgetControlsMap.has(widgetId)) {
this.addWidgetControls(widgetElement, widgetId);
} else {
// Update position if controls already exist
const data = this.widgetControlsMap.get(widgetId);
if (data && data.controls) {
this.updateControlPosition(data.controls, widgetElement);
}
}
});
console.log('[EditModeManager] Synced controls for', widgets.length, 'widgets');
}
/**
* Remove all widget controls
* Called when clearing the grid or switching tabs
*/
removeAllControls() {
this.widgetControlsMap.forEach((data, widgetId) => {
if (data.controls) {
data.controls.remove();
}
});
this.widgetControlsMap.clear();
console.log('[EditModeManager] Removed all widget controls');
}
/**
* Create a control button
* @param {string} icon - Button icon/text
+45 -1
View File
@@ -28,6 +28,7 @@ export class ResizeHandler {
constructor(gridEngine, options = {}) {
this.gridEngine = gridEngine;
this.editManager = options.editManager || null; // Reference to EditModeManager for lock state
this.resizeHandlesOverlay = options.resizeHandlesOverlay || null; // Overlay container for handles
this.options = {
showDimensions: true,
showGrid: true,
@@ -74,7 +75,19 @@ export class ResizeHandler {
initWidget(element, widget, onResizeEnd, constraints = {}) {
// Create resize handles
const handles = this.createResizeHandles();
element.appendChild(handles);
// Store reference to widget element for positioning
handles.dataset.widgetId = element.id;
// Append to overlay instead of widget to prevent overflow/scrollbar issues
if (this.resizeHandlesOverlay) {
this.resizeHandlesOverlay.appendChild(handles);
// Position handles to match widget bounds
this.updateHandlePosition(handles, element);
} else {
// Fallback to old behavior if overlay not available
element.appendChild(handles);
}
// Store constraints
const widgetConstraints = {
@@ -215,6 +228,25 @@ export class ResizeHandler {
return container;
}
/**
* Update handle container position to match widget bounds
* @param {HTMLElement} handles - Resize handles container
* @param {HTMLElement} element - Widget element
*/
updateHandlePosition(handles, element) {
if (!handles || !element) return;
const overlay = this.resizeHandlesOverlay;
if (!overlay) return;
// Use offset properties for parent-relative positioning
// Both widget and overlay are children of the same grid container
handles.style.left = `${element.offsetLeft}px`;
handles.style.top = `${element.offsetTop}px`;
handles.style.width = `${element.offsetWidth}px`;
handles.style.height = `${element.offsetHeight}px`;
}
/**
* Start resize operation
* @param {MouseEvent|Touch} e - Pointer event
@@ -416,6 +448,12 @@ export class ResizeHandler {
onResizeEnd(widget, widget.w, widget.h, widget.x, widget.y);
}
// Update handle positions to match new widget size
const handlerData = this.resizeHandlers.get(element);
if (handlerData && handlerData.handles) {
this.updateHandlePosition(handlerData.handles, element);
}
this.cleanup();
console.log('[ResizeHandler] Resize completed:', widget.id, `${widget.w}×${widget.h} at (${widget.x}, ${widget.y})`);
}
@@ -445,6 +483,12 @@ export class ResizeHandler {
// Remove resizing class
element.classList.remove('resizing');
// Update handle positions to match restored widget size
const handlerData = this.resizeHandlers.get(element);
if (handlerData && handlerData.handles) {
this.updateHandlePosition(handlerData.handles, element);
}
this.cleanup();
console.log('[ResizeHandler] Resize cancelled');
}