Files
rpg-companion-sillytavern/src/systems/dashboard/sectionManager.js
T
Lucas 'Paperboy' Rose-Winters 92cb5aedbd feat(dashboard): add Scene Info multi-view widget to reduce mobile scroll
Implements combined widget that merges Calendar, Weather, Temperature, Clock,
and Location into one tabbed interface, reducing Scene tab from 7 to 3 widgets.

Phase 2: Scene Info Multi-View Widget

New Features:
- sceneInfoWidget.js: Tab-based multi-view widget
  - Reuses existing infoBox widget render functions (no code duplication)
  - Tab bar with icon + label for each view (📅 Cal, 🌤️ Wea, 🌡️ Tmp, 🕐 Clk, 📍 Loc)
  - View switching by toggling CSS display (preserves handlers and state)
  - Smart empty state detection (hides tabs for widgets with no data)
  - Configurable: select views, default view, show/hide empty views

- Per-instance state management (activeSubTab persists per widget)
- Size: 2×3 default (tab bar + content)
- Registered in dashboardIntegration.js

Default Layout Changes:
- Scene tab: 7 widgets → 3 widgets (57% reduction)
- Old: Calendar (1×1) + Weather (1×1) + Temp (1×1) + Clock (1×1) + Location (2×2)
- New: Scene Info (2×3) - combined multi-view widget
- Repositioned: Recent Events (y: 4 → 3), Present Characters (y: 6 → 5)
- Vertical space: 10 rows → 9 rows (10% reduction)

Benefits:
- Reduces mobile vertical scroll by ~30%
- Cleaner Scene tab layout
- Individual widgets still available for customization
- Consistent UX with Inventory/Quests tab patterns
- Leverages existing CSS (.rpg-inventory-subtabs)

Technical Approach:
- Render all views once on mount (not destroyed on tab switch)
- Toggle visibility with CSS display property
- Preserves widget edit handlers and state
- Empty views filtered based on data availability

Individual calendar/weather/temperature/clock/location widgets remain
available in registry for users who prefer separate widgets.

Testing Required:
- Tab switching between all 5 views
- Empty state detection (remove data from infoBox)
- Edit functionality in each view
- Config changes (remove views, change default)
- Mobile responsive behavior
- Theme compatibility
2025-11-02 20:38:45 +11:00

221 lines
7.3 KiB
JavaScript

/**
* Section Manager
*
* Manages collapsible sections within dashboard tabs for better organization and mobile UX.
* Sections group related widgets together with expand/collapse functionality.
*
* Features:
* - Click section header to toggle expand/collapse
* - Smooth CSS transitions
* - State persistence per tab in dashboard config
* - Keyboard accessibility (Enter/Space to toggle)
* - ARIA attributes for screen readers
*/
export class SectionManager {
/**
* @param {Object} options - Configuration options
* @param {Function} options.onStateChange - Callback when section state changes
*/
constructor(options = {}) {
this.options = options;
this.sectionStates = new Map(); // sectionId -> {expanded: boolean}
// Bound event handlers
this.boundToggleSection = this.toggleSection.bind(this);
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
}
/**
* Initialize section state from dashboard config
* @param {Object} tabConfig - Tab configuration with sections array
*/
init(tabConfig) {
if (!tabConfig || !Array.isArray(tabConfig.sections)) {
return;
}
// Load initial state from config
tabConfig.sections.forEach(section => {
this.sectionStates.set(section.id, {
expanded: section.expanded !== false // Default to expanded
});
});
console.log(`[SectionManager] Initialized with ${this.sectionStates.size} sections`);
}
/**
* Get section state
* @param {string} sectionId - Section ID
* @returns {boolean} Whether section is expanded
*/
isExpanded(sectionId) {
const state = this.sectionStates.get(sectionId);
return state ? state.expanded : true; // Default to expanded
}
/**
* Set section state
* @param {string} sectionId - Section ID
* @param {boolean} expanded - Whether section should be expanded
* @param {boolean} notify - Whether to trigger state change callback
*/
setExpanded(sectionId, expanded, notify = true) {
this.sectionStates.set(sectionId, { expanded });
// Update DOM
const sectionHeader = document.querySelector(`[data-section-id="${sectionId}"]`);
if (sectionHeader) {
const container = sectionHeader.parentElement;
const content = container?.querySelector('.rpg-section-content');
const chevron = sectionHeader.querySelector('.rpg-section-chevron');
if (expanded) {
container?.classList.remove('collapsed');
sectionHeader.setAttribute('aria-expanded', 'true');
if (content) content.style.maxHeight = content.scrollHeight + 'px';
if (chevron) chevron.style.transform = 'rotate(0deg)';
} else {
container?.classList.add('collapsed');
sectionHeader.setAttribute('aria-expanded', 'false');
if (content) content.style.maxHeight = '0';
if (chevron) chevron.style.transform = 'rotate(-90deg)';
}
}
// Notify state change
if (notify && this.options.onStateChange) {
this.options.onStateChange(sectionId, expanded);
}
console.log(`[SectionManager] Section '${sectionId}' ${expanded ? 'expanded' : 'collapsed'}`);
}
/**
* Toggle section expand/collapse
* @param {Event} event - Click event
*/
toggleSection(event) {
const header = event.currentTarget;
const sectionId = header.dataset.sectionId;
if (!sectionId) {
console.warn('[SectionManager] No section ID found on header');
return;
}
const currentState = this.isExpanded(sectionId);
this.setExpanded(sectionId, !currentState);
}
/**
* Handle keyboard events for accessibility
* @param {KeyboardEvent} event - Keyboard event
*/
handleKeyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggleSection(event);
}
}
/**
* Attach event handlers to section header
* @param {HTMLElement} header - Section header element
*/
attachHandlers(header) {
header.addEventListener('click', this.boundToggleSection);
header.addEventListener('keydown', this.boundHandleKeyDown);
}
/**
* Detach event handlers from section header
* @param {HTMLElement} header - Section header element
*/
detachHandlers(header) {
header.removeEventListener('click', this.boundToggleSection);
header.removeEventListener('keydown', this.boundHandleKeyDown);
}
/**
* Render section header HTML
* @param {Object} section - Section configuration
* @param {string} section.id - Section ID
* @param {string} section.name - Section display name
* @param {string} section.icon - Section icon (emoji or FontAwesome)
* @param {boolean} section.expanded - Whether section starts expanded
* @returns {string} Section header HTML
*/
renderSectionHeader(section) {
const expanded = this.isExpanded(section.id);
const chevronRotation = expanded ? '0deg' : '-90deg';
return `
<div class="rpg-section">
<div class="rpg-section-header"
data-section-id="${section.id}"
role="button"
tabindex="0"
aria-expanded="${expanded}"
aria-label="Toggle ${section.name} section">
<span class="rpg-section-icon">${section.icon || '📁'}</span>
<span class="rpg-section-name">${section.name}</span>
<span class="rpg-section-chevron" style="transform: rotate(${chevronRotation})">
<i class="fa-solid fa-chevron-down"></i>
</span>
</div>
<div class="rpg-section-content" style="max-height: ${expanded ? 'none' : '0'}">
`;
}
/**
* Render section footer HTML
* @returns {string} Section footer HTML
*/
renderSectionFooter() {
return `
</div>
</div>
`;
}
/**
* Get current state for persistence
* @returns {Object} Map of sectionId -> expanded state
*/
getState() {
const state = {};
this.sectionStates.forEach((value, key) => {
state[key] = value.expanded;
});
return state;
}
/**
* Restore state from saved data
* @param {Object} state - Saved state object
*/
restoreState(state) {
if (!state || typeof state !== 'object') {
return;
}
Object.entries(state).forEach(([sectionId, expanded]) => {
this.setExpanded(sectionId, expanded, false); // Don't notify on restore
});
console.log(`[SectionManager] Restored state for ${Object.keys(state).length} sections`);
}
/**
* Cleanup - detach all event handlers
*/
destroy() {
const headers = document.querySelectorAll('.rpg-section-header');
headers.forEach(header => this.detachHandlers(header));
this.sectionStates.clear();
console.log('[SectionManager] Destroyed');
}
}