1017 lines
30 KiB
JavaScript
1017 lines
30 KiB
JavaScript
/**
|
|
* UI Store - Centralized UI state management
|
|
*
|
|
* Design Flaw Fixed: Round 5 Q1 - UI state scattered in components (theme, activeTab in LayoutComponent)
|
|
* Design Flaw Fixed: Round 5 Q3 - Multiple sources of truth (UI state in component data())
|
|
* Design Flaw Fixed: Round 9 Q1 - No error notifications (console-only errors)
|
|
* Design Flaw Fixed: Round 6 Q1 - No theme system (cannot swap light/dark easily)
|
|
*
|
|
* Purpose:
|
|
* - Centralized UI state (theme, sidebar, tabs, modals)
|
|
* - Toast notifications (success, error, warning, info)
|
|
* - Modal/dialog management
|
|
* - Loading overlays and progress indicators
|
|
* - Responsive breakpoint tracking
|
|
* - Accessibility settings
|
|
*
|
|
* REFACTORED: Now uses NPM Pinia package instead of vendored libs
|
|
*/
|
|
|
|
// Import Pinia from NPM package
|
|
import { defineStore, acceptHMRUpdate } from 'pinia';
|
|
// Feature flag (single source of truth). useMapTheme.js has NO imports of its own
|
|
// and receives uiStore as an argument, so importing the flag here is cycle-free.
|
|
import { THEME_TOGGLE } from '@map/composables/useMapTheme.js';
|
|
|
|
let _uiStoreInstanceCounter = 0;
|
|
|
|
export const useUIStore = defineStore('ui', {
|
|
state: () => {
|
|
const instanceId = ++_uiStoreInstanceCounter;
|
|
return {
|
|
// Diagnostic: unique ID per store instance
|
|
_storeInstanceId: instanceId,
|
|
|
|
// ========================================
|
|
// Theme & Appearance
|
|
// ========================================
|
|
/**
|
|
* Current theme mode
|
|
* 'light' | 'dark' | 'auto' (follows system preference)
|
|
*/
|
|
theme: 'light',
|
|
|
|
/**
|
|
* Color scheme variant
|
|
* Used for white-labeling and customization
|
|
*/
|
|
colorScheme: 'default', // 'default' | 'blue' | 'green' | 'purple' etc.
|
|
|
|
/**
|
|
* Reduced motion preference (accessibility)
|
|
*/
|
|
reducedMotion: false,
|
|
|
|
/**
|
|
* High contrast mode (accessibility)
|
|
*/
|
|
highContrast: false,
|
|
|
|
// ========================================
|
|
// Layout & Navigation
|
|
// ========================================
|
|
/**
|
|
* Currently active tab/map
|
|
*/
|
|
activeTab: 'doxa-regions', // Default from mapDefaults
|
|
|
|
/**
|
|
* Sidebar/legend collapsed state
|
|
*/
|
|
sidebarCollapsed: false,
|
|
|
|
/**
|
|
* Mobile menu open state
|
|
*/
|
|
mobileMenuOpen: false,
|
|
|
|
// ========================================
|
|
// Mobile UI (NEW - Mobile-Friendly Implementation)
|
|
// ========================================
|
|
/**
|
|
* Mobile Legend Bottom Sheet State
|
|
* - 'collapsed': ~70px height, caret pointing right →
|
|
* - 'open': 30vh height, caret pointing down ↓
|
|
* - 'fullyOpen': 90vh height, caret pointing down ↓
|
|
*/
|
|
legendState: 'collapsed',
|
|
|
|
/**
|
|
* Custom legend height (used during dragging for smooth transitions)
|
|
* Cleared when snapping to a state
|
|
*/
|
|
_customLegendHeight: null,
|
|
|
|
/**
|
|
* Search Mode State
|
|
* Controls hamburger icon (☰) vs cancel icon (✕)
|
|
* true = search active (show ✕)
|
|
* false = normal mode (show ☰)
|
|
*/
|
|
searchMode: false,
|
|
|
|
/**
|
|
* Hamburger Menu State (75% width overlay)
|
|
* true = menu open
|
|
* false = menu closed
|
|
*/
|
|
hamburgerMenuOpen: false,
|
|
|
|
/**
|
|
* Legend Mode (NEW - Prayer Progress Implementation)
|
|
* Controls which legend content to display
|
|
* 'prayer' = Prayer Progress legend (red/orange/green 3-tier)
|
|
* 'detail' = People Group Detail mode (image + action buttons)
|
|
*/
|
|
legendMode: 'prayer',
|
|
|
|
/**
|
|
* Prayer Filter State (NEW - Prayer Progress Implementation)
|
|
* Controls map pin visibility filtering
|
|
* null = show all pins
|
|
* 'noPrayer' = show only groups without prayer
|
|
* 'hasPrayer' = show only groups with partial prayer
|
|
* 'fullPrayer' = show only groups with full prayer coverage
|
|
*/
|
|
prayerFilter: null,
|
|
|
|
/**
|
|
* Engagement Filter State
|
|
* null = show all | 'notEngaged' | 'hasEngagement'
|
|
*/
|
|
engagementFilter: null,
|
|
|
|
/**
|
|
* Adoption Filter State
|
|
* null = show all | 'notAdopted' | 'hasAdoption'
|
|
*/
|
|
adoptionFilter: null,
|
|
|
|
/**
|
|
* Religion-family filter — null | family-letter ('C', 'M', 'H', 'B', 'E', 'J', 'S', 'N', 'O', 'U').
|
|
* When set, the religion-tab pin layer dims pins whose religion code's
|
|
* first letter doesn't match.
|
|
*/
|
|
religionFilter: null,
|
|
|
|
/**
|
|
* Selected People Group (NEW - People Group Detail Implementation)
|
|
* When user clicks a pin, this stores the full people group data
|
|
* Used to display detail view with image and action buttons
|
|
*/
|
|
selectedPeopleGroup: null,
|
|
|
|
/**
|
|
* Current viewport breakpoint
|
|
* Updated via resize observer
|
|
*/
|
|
breakpoint: 'desktop', // 'mobile' | 'tablet' | 'desktop' | 'wide'
|
|
|
|
/**
|
|
* Window dimensions
|
|
*/
|
|
windowSize: {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight
|
|
},
|
|
|
|
// ========================================
|
|
// Notifications (Toast System)
|
|
// ========================================
|
|
/**
|
|
* Array of active toast notifications
|
|
* Auto-removed after timeout
|
|
*/
|
|
toasts: [],
|
|
|
|
/**
|
|
* Next toast ID (auto-increment)
|
|
*/
|
|
nextToastId: 1,
|
|
|
|
/**
|
|
* Default toast duration (ms)
|
|
*/
|
|
toastDuration: 5000,
|
|
|
|
// ========================================
|
|
// Modals & Dialogs
|
|
// ========================================
|
|
/**
|
|
* Currently open modals
|
|
* Stack-based (multiple modals supported)
|
|
*/
|
|
openModals: [],
|
|
|
|
/**
|
|
* Modal data/props
|
|
* Key: modalId, Value: modal data
|
|
*/
|
|
modalData: new Map(),
|
|
|
|
// ========================================
|
|
// Loading States
|
|
// ========================================
|
|
/**
|
|
* Global loading overlay
|
|
* Shows full-screen spinner
|
|
*/
|
|
isGlobalLoading: false,
|
|
|
|
/**
|
|
* Global loading message
|
|
*/
|
|
globalLoadingMessage: 'Loading...',
|
|
|
|
/**
|
|
* Loading states by key
|
|
* For granular loading indicators
|
|
*/
|
|
loadingStates: new Map(),
|
|
|
|
// ========================================
|
|
// User Interactions
|
|
// ========================================
|
|
/**
|
|
* Recently clicked items (for undo/redo)
|
|
*/
|
|
interactionHistory: [],
|
|
|
|
/**
|
|
* Max history length
|
|
*/
|
|
maxHistoryLength: 20,
|
|
|
|
/**
|
|
* Keyboard shortcuts enabled
|
|
*/
|
|
keyboardShortcutsEnabled: true,
|
|
|
|
// ========================================
|
|
// Accessibility
|
|
// ========================================
|
|
/**
|
|
* Screen reader announcements
|
|
*/
|
|
screenReaderAnnouncement: '',
|
|
|
|
/**
|
|
* Focus trap active (for modals)
|
|
*/
|
|
focusTrapActive: false,
|
|
|
|
// ========================================
|
|
// Developer Tools
|
|
// ========================================
|
|
/**
|
|
* Debug mode enabled
|
|
*/
|
|
debugMode: false,
|
|
|
|
/**
|
|
* Show performance overlay
|
|
*/
|
|
showPerformanceOverlay: false
|
|
}; },
|
|
|
|
getters: {
|
|
/**
|
|
* Check if dark theme is active
|
|
*/
|
|
isDarkTheme: (state) => {
|
|
// FEATURE FLAG: THEME_TOGGLE off (see library/composables/useMapTheme.js)
|
|
// forces the UI CHROME (controls, legend, search, drawer) to LIGHT.
|
|
// The BASEMAP is pinned separately to the dark/colourful look in
|
|
// useMapTheme.applyTheme(), so the map keeps its colour strategy while
|
|
// everything around it is light. Restore user-switchable theming by
|
|
// setting THEME_TOGGLE = true.
|
|
if (!THEME_TOGGLE) return false;
|
|
if (state.theme === 'auto') {
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
}
|
|
return state.theme === 'dark';
|
|
},
|
|
|
|
/**
|
|
* Check if mobile viewport
|
|
*/
|
|
isMobile: (state) => {
|
|
return state.breakpoint === 'mobile';
|
|
},
|
|
|
|
/**
|
|
* Check if tablet viewport
|
|
*/
|
|
isTablet: (state) => {
|
|
return state.breakpoint === 'tablet';
|
|
},
|
|
|
|
/**
|
|
* Check if desktop or wider
|
|
*/
|
|
isDesktop: (state) => {
|
|
return state.breakpoint === 'desktop' || state.breakpoint === 'wide';
|
|
},
|
|
|
|
// ========================================
|
|
// Mobile Legend Getters
|
|
// ========================================
|
|
|
|
/**
|
|
* Legend height based on state (CSS value)
|
|
*/
|
|
legendHeight: (state) => {
|
|
// Use custom height if set (during dragging)
|
|
if (state._customLegendHeight) {
|
|
return state._customLegendHeight
|
|
}
|
|
|
|
// Default heights based on state
|
|
if (state.legendState === 'collapsed') return '48px'
|
|
if (state.legendState === 'open') return '30%'
|
|
if (state.legendState === 'fullyOpen') return 'calc(100% - 80px)' // 80px reserves top room: search bar (10+36) + gap (34)
|
|
return '48px'
|
|
},
|
|
|
|
/**
|
|
* Caret rotation degrees
|
|
* collapsed: 0deg (→ right)
|
|
* open/fullyOpen: 90deg (↓ down)
|
|
*/
|
|
caretRotation: (state) => {
|
|
return state.legendState === 'collapsed' ? 0 : 90
|
|
},
|
|
|
|
/**
|
|
* Check if pull tab should be visible
|
|
* Only visible in 'open' (30%) and 'fullyOpen' (90%) states
|
|
*/
|
|
showPullTab: (state) => {
|
|
return state.legendState === 'open' || state.legendState === 'fullyOpen'
|
|
},
|
|
|
|
/**
|
|
* Get toasts by type
|
|
*/
|
|
getToastsByType: (state) => (type) => {
|
|
return state.toasts.filter(toast => toast.type === type);
|
|
},
|
|
|
|
/**
|
|
* Check if any modal is open
|
|
*/
|
|
hasOpenModal: (state) => {
|
|
return state.openModals.length > 0;
|
|
},
|
|
|
|
/**
|
|
* Get top modal (most recent)
|
|
*/
|
|
topModal: (state) => {
|
|
if (state.openModals.length === 0) return null;
|
|
return state.openModals[state.openModals.length - 1];
|
|
},
|
|
|
|
/**
|
|
* Check if specific modal is open
|
|
*/
|
|
isModalOpen: (state) => (modalId) => {
|
|
return state.openModals.includes(modalId);
|
|
},
|
|
|
|
/**
|
|
* Check if any loading state is active
|
|
*/
|
|
isAnyLoading: (state) => {
|
|
return state.isGlobalLoading || state.loadingStates.size > 0;
|
|
},
|
|
|
|
/**
|
|
* Get loading state by key
|
|
*/
|
|
isLoading: (state) => (key) => {
|
|
return state.loadingStates.get(key) || false;
|
|
}
|
|
},
|
|
|
|
actions: {
|
|
// ========================================
|
|
// Theme Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Set theme mode
|
|
* @param {string} theme - 'light' | 'dark' | 'auto'
|
|
*/
|
|
setTheme(theme) {
|
|
const validThemes = ['light', 'dark', 'auto'];
|
|
if (!validThemes.includes(theme)) {
|
|
return;
|
|
}
|
|
// FEATURE FLAG: with THEME_TOGGLE off there is exactly ONE theme — light.
|
|
// Coerce here (the single funnel every caller goes through: ProfileLoader's
|
|
// pre-render restore, init()'s localStorage load, and toggleTheme) so a
|
|
// stale persisted 'dark' can never resurrect dark chrome.
|
|
if (!THEME_TOGGLE) theme = 'light';
|
|
|
|
this.theme = theme;
|
|
|
|
// Apply to DOM. NOTE: with THEME_TOGGLE off, isDarkTheme is always false,
|
|
// so data-theme is pinned to 'light' — the UI chrome stays light no matter
|
|
// what is persisted in localStorage from a previous session.
|
|
const isDark = this.isDarkTheme;
|
|
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
|
|
|
// Save to localStorage (sandboxed iframe throws — persistence is best-effort)
|
|
try { localStorage.setItem('theme', theme); } catch (e) { /* sandboxed: no persistence */ }
|
|
|
|
},
|
|
|
|
/**
|
|
* Toggle between light and dark theme
|
|
*/
|
|
toggleTheme() {
|
|
const newTheme = this.theme === 'light' ? 'dark' : 'light';
|
|
this.setTheme(newTheme);
|
|
},
|
|
|
|
/**
|
|
* Set color scheme variant
|
|
*/
|
|
setColorScheme(scheme) {
|
|
this.colorScheme = scheme;
|
|
document.documentElement.setAttribute('data-color-scheme', scheme);
|
|
},
|
|
|
|
/**
|
|
* Toggle reduced motion
|
|
*/
|
|
toggleReducedMotion() {
|
|
this.reducedMotion = !this.reducedMotion;
|
|
document.documentElement.setAttribute('data-reduced-motion', this.reducedMotion);
|
|
},
|
|
|
|
/**
|
|
* Toggle high contrast mode
|
|
*/
|
|
toggleHighContrast() {
|
|
this.highContrast = !this.highContrast;
|
|
document.documentElement.setAttribute('data-high-contrast', this.highContrast);
|
|
},
|
|
|
|
// ========================================
|
|
// Navigation Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Set active tab/map
|
|
* @param {string} tabId - Tab identifier
|
|
*/
|
|
setActiveTab(tabId) {
|
|
this.activeTab = tabId;
|
|
|
|
// Add to history
|
|
this.addToHistory({ type: 'tab-change', tabId, timestamp: Date.now() });
|
|
},
|
|
|
|
/**
|
|
* Toggle sidebar collapsed state
|
|
*/
|
|
toggleSidebar() {
|
|
this.sidebarCollapsed = !this.sidebarCollapsed;
|
|
},
|
|
|
|
/**
|
|
* Open mobile menu
|
|
*/
|
|
openMobileMenu() {
|
|
this.mobileMenuOpen = true;
|
|
},
|
|
|
|
/**
|
|
* Close mobile menu
|
|
*/
|
|
closeMobileMenu() {
|
|
this.mobileMenuOpen = false;
|
|
},
|
|
|
|
// ========================================
|
|
// Mobile UI Actions (NEW - Mobile-Friendly Implementation)
|
|
// ========================================
|
|
|
|
/**
|
|
* Set legend mode (prayer progress vs people group detail)
|
|
* @param {string} mode - 'prayer' | 'detail'
|
|
*/
|
|
setLegendMode(mode) {
|
|
const validModes = ['prayer', 'detail'];
|
|
if (!validModes.includes(mode)) {
|
|
return;
|
|
}
|
|
this.legendMode = mode;
|
|
},
|
|
|
|
/**
|
|
* Set prayer filter (for pin visibility)
|
|
* @param {string|null} filter - null | 'noPrayer' | 'hasPrayer' | 'fullPrayer'
|
|
*/
|
|
setPrayerFilter(filter) {
|
|
const validFilters = [null, 'noPrayer', 'hasPrayer', 'fullPrayer'];
|
|
if (!validFilters.includes(filter)) {
|
|
return;
|
|
}
|
|
this.prayerFilter = filter;
|
|
},
|
|
|
|
/**
|
|
* Set engagement filter (for pin visibility)
|
|
* @param {string|null} filter - null | 'notEngaged' | 'hasEngagement'
|
|
*/
|
|
setEngagementFilter(filter) {
|
|
const validFilters = [null, 'notEngaged', 'hasEngagement'];
|
|
if (!validFilters.includes(filter)) {
|
|
return;
|
|
}
|
|
this.engagementFilter = filter;
|
|
},
|
|
|
|
/**
|
|
* Set adoption filter (for pin visibility)
|
|
* @param {string|null} filter - null | 'notAdopted' | 'hasAdoption'
|
|
*/
|
|
setAdoptionFilter(filter) {
|
|
const validFilters = [null, 'notAdopted', 'hasAdoption'];
|
|
if (!validFilters.includes(filter)) {
|
|
return;
|
|
}
|
|
this.adoptionFilter = filter;
|
|
},
|
|
|
|
/**
|
|
* Set religion-family filter (for pin dim).
|
|
* @param {string|null} filter - null | family letter ('C', 'M', 'H', 'B', 'E', 'J', 'S', 'N', 'O', 'U')
|
|
*/
|
|
setReligionFilter(filter) {
|
|
const valid = [null, 'C', 'M', 'H', 'B', 'E', 'J', 'S', 'N', 'O', 'U'];
|
|
if (!valid.includes(filter)) return;
|
|
this.religionFilter = filter;
|
|
},
|
|
|
|
/**
|
|
* Select a people group (for detail view)
|
|
* @param {object|null} peopleGroup - People group data object or null
|
|
*/
|
|
selectPeopleGroup(peopleGroup) {
|
|
this.selectedPeopleGroup = peopleGroup;
|
|
|
|
if (peopleGroup) {
|
|
this._legendStateBeforeDetail = this.legendState;
|
|
this.legendState = 'open';
|
|
this._customLegendHeight = null;
|
|
this.setLegendMode('detail');
|
|
} else {
|
|
this.legendState = this._legendStateBeforeDetail || 'open';
|
|
this._customLegendHeight = null;
|
|
this.setLegendMode('prayer');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Set legend state (collapsed/open/fullyOpen)
|
|
* @param {string} state - 'collapsed' | 'open' | 'fullyOpen'
|
|
*/
|
|
setLegendState(state) {
|
|
const validStates = ['collapsed', 'open', 'fullyOpen'];
|
|
if (!validStates.includes(state)) {
|
|
return;
|
|
}
|
|
this.legendState = state;
|
|
},
|
|
|
|
/**
|
|
* Toggle search mode (controls hamburger vs cancel icon)
|
|
*/
|
|
toggleSearchMode() {
|
|
this.searchMode = !this.searchMode;
|
|
},
|
|
|
|
/**
|
|
* Set search mode explicitly
|
|
* @param {boolean} enabled - true = search active, false = normal
|
|
*/
|
|
setSearchMode(enabled) {
|
|
this.searchMode = enabled;
|
|
},
|
|
|
|
/**
|
|
* Toggle hamburger menu
|
|
*/
|
|
toggleHamburgerMenu() {
|
|
this.hamburgerMenuOpen = !this.hamburgerMenuOpen;
|
|
},
|
|
|
|
/**
|
|
* Update viewport breakpoint
|
|
* Called by resize observer
|
|
* @param {number} width - Window width
|
|
*/
|
|
updateBreakpoint(width) {
|
|
let newBreakpoint;
|
|
if (width < 768) {
|
|
newBreakpoint = 'mobile';
|
|
} else if (width < 1024) {
|
|
newBreakpoint = 'tablet';
|
|
} else if (width < 1920) {
|
|
newBreakpoint = 'desktop';
|
|
} else {
|
|
newBreakpoint = 'wide';
|
|
}
|
|
|
|
if (newBreakpoint !== this.breakpoint) {
|
|
this.breakpoint = newBreakpoint;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update window size
|
|
* Called by resize observer
|
|
*/
|
|
updateWindowSize(width, height) {
|
|
this.windowSize.width = width;
|
|
this.windowSize.height = height;
|
|
this.updateBreakpoint(width);
|
|
},
|
|
|
|
// ========================================
|
|
// Toast Notification Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Show a toast notification
|
|
* @param {object} options - { message, type, duration, action }
|
|
* @returns {number} Toast ID
|
|
*/
|
|
showToast(options) {
|
|
const {
|
|
message,
|
|
type = 'info', // 'success' | 'error' | 'warning' | 'info'
|
|
duration = this.toastDuration,
|
|
action = null, // { label, callback }
|
|
persistent = false
|
|
} = options;
|
|
|
|
const toast = {
|
|
id: this.nextToastId++,
|
|
message,
|
|
type,
|
|
duration,
|
|
action,
|
|
persistent,
|
|
createdAt: Date.now()
|
|
};
|
|
|
|
this.toasts.push(toast);
|
|
|
|
// Auto-remove after duration (unless persistent)
|
|
if (!persistent && duration > 0) {
|
|
setTimeout(() => {
|
|
this.removeToast(toast.id);
|
|
}, duration);
|
|
}
|
|
|
|
return toast.id;
|
|
},
|
|
|
|
/**
|
|
* Convenience methods for common toast types
|
|
*/
|
|
showSuccess(message, options = {}) {
|
|
return this.showToast({ message, type: 'success', ...options });
|
|
},
|
|
|
|
showError(message, options = {}) {
|
|
return this.showToast({ message, type: 'error', duration: 7000, ...options });
|
|
},
|
|
|
|
showWarning(message, options = {}) {
|
|
return this.showToast({ message, type: 'warning', ...options });
|
|
},
|
|
|
|
showInfo(message, options = {}) {
|
|
return this.showToast({ message, type: 'info', ...options });
|
|
},
|
|
|
|
/**
|
|
* Remove a toast by ID
|
|
*/
|
|
removeToast(toastId) {
|
|
const index = this.toasts.findIndex(t => t.id === toastId);
|
|
if (index !== -1) {
|
|
this.toasts.splice(index, 1);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clear all toasts
|
|
*/
|
|
clearAllToasts() {
|
|
this.toasts = [];
|
|
},
|
|
|
|
// ========================================
|
|
// Modal Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Open a modal
|
|
* @param {string} modalId - Unique modal identifier
|
|
* @param {object} data - Modal data/props
|
|
*/
|
|
openModal(modalId, data = {}) {
|
|
if (!this.openModals.includes(modalId)) {
|
|
this.openModals.push(modalId);
|
|
this.modalData.set(modalId, data);
|
|
this.focusTrapActive = true;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Close a modal
|
|
* @param {string} modalId - Modal identifier to close (optional - closes top if not specified)
|
|
*/
|
|
closeModal(modalId = null) {
|
|
if (modalId) {
|
|
// Close specific modal
|
|
const index = this.openModals.indexOf(modalId);
|
|
if (index !== -1) {
|
|
this.openModals.splice(index, 1);
|
|
this.modalData.delete(modalId);
|
|
}
|
|
} else {
|
|
// Close top modal
|
|
const topModalId = this.openModals.pop();
|
|
if (topModalId) {
|
|
this.modalData.delete(topModalId);
|
|
}
|
|
}
|
|
|
|
// Disable focus trap if no modals open
|
|
if (this.openModals.length === 0) {
|
|
this.focusTrapActive = false;
|
|
}
|
|
|
|
},
|
|
|
|
/**
|
|
* Close all modals
|
|
*/
|
|
closeAllModals() {
|
|
this.openModals = [];
|
|
this.modalData.clear();
|
|
this.focusTrapActive = false;
|
|
},
|
|
|
|
/**
|
|
* Get modal data
|
|
*/
|
|
getModalData(modalId) {
|
|
return this.modalData.get(modalId);
|
|
},
|
|
|
|
// ========================================
|
|
// Loading State Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Show global loading overlay
|
|
*/
|
|
showGlobalLoading(message = 'Loading...') {
|
|
this.isGlobalLoading = true;
|
|
this.globalLoadingMessage = message;
|
|
},
|
|
|
|
/**
|
|
* Hide global loading overlay
|
|
*/
|
|
hideGlobalLoading() {
|
|
this.isGlobalLoading = false;
|
|
},
|
|
|
|
/**
|
|
* Set loading state by key
|
|
*/
|
|
setLoading(key, isLoading = true) {
|
|
if (isLoading) {
|
|
this.loadingStates.set(key, true);
|
|
} else {
|
|
this.loadingStates.delete(key);
|
|
}
|
|
},
|
|
|
|
// ========================================
|
|
// History Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Add item to interaction history
|
|
* @private
|
|
*/
|
|
addToHistory(item) {
|
|
this.interactionHistory.push(item);
|
|
|
|
// Trim history if too long
|
|
if (this.interactionHistory.length > this.maxHistoryLength) {
|
|
this.interactionHistory.shift();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clear interaction history
|
|
*/
|
|
clearHistory() {
|
|
this.interactionHistory = [];
|
|
},
|
|
|
|
// ========================================
|
|
// Accessibility Actions
|
|
// ========================================
|
|
|
|
/**
|
|
* Announce message to screen readers
|
|
*/
|
|
announce(message, priority = 'polite') {
|
|
this.screenReaderAnnouncement = message;
|
|
|
|
// Clear after delay to allow re-announcement
|
|
setTimeout(() => {
|
|
if (this.screenReaderAnnouncement === message) {
|
|
this.screenReaderAnnouncement = '';
|
|
}
|
|
}, 1000);
|
|
},
|
|
|
|
/**
|
|
* Toggle keyboard shortcuts
|
|
*/
|
|
toggleKeyboardShortcuts() {
|
|
this.keyboardShortcutsEnabled = !this.keyboardShortcutsEnabled;
|
|
},
|
|
|
|
// ========================================
|
|
// Developer Tools
|
|
// ========================================
|
|
|
|
/**
|
|
* Toggle debug mode
|
|
*/
|
|
toggleDebugMode() {
|
|
this.debugMode = !this.debugMode;
|
|
},
|
|
|
|
/**
|
|
* Toggle performance overlay
|
|
*/
|
|
togglePerformanceOverlay() {
|
|
this.showPerformanceOverlay = !this.showPerformanceOverlay;
|
|
},
|
|
|
|
// ========================================
|
|
// Mobile Legend Actions (NEW)
|
|
// ========================================
|
|
|
|
/**
|
|
* Toggle legend — tri-state cascade so fullscreen doesn't slam all
|
|
* the way closed on one caret tap (feedback 2026-04-20).
|
|
*
|
|
* fullyOpen (90%) → open (30%) ← keeps user's view of the map
|
|
* open (30%) → collapsed ← next tap closes
|
|
* collapsed → open (30%) ← re-open
|
|
*/
|
|
toggleLegend() {
|
|
if (this.legendState === 'fullyOpen') {
|
|
this.legendState = 'open'
|
|
} else if (this.legendState === 'open') {
|
|
this.legendState = 'collapsed'
|
|
} else {
|
|
this.legendState = 'open'
|
|
}
|
|
this._customLegendHeight = null
|
|
},
|
|
|
|
/**
|
|
* Open legend to 30% (from collapsed)
|
|
*/
|
|
openLegend() {
|
|
this.legendState = 'open'
|
|
this._customLegendHeight = null // Clear custom height
|
|
},
|
|
|
|
/**
|
|
* Fully open legend to 90%
|
|
*/
|
|
fullyOpenLegend() {
|
|
this.legendState = 'fullyOpen'
|
|
this._customLegendHeight = null // Clear custom height
|
|
},
|
|
|
|
/**
|
|
* Collapse legend completely
|
|
*/
|
|
collapseLegend() {
|
|
this.legendState = 'collapsed'
|
|
this._customLegendHeight = null // Clear custom height
|
|
},
|
|
|
|
/**
|
|
* Set legend height dynamically (for smooth dragging)
|
|
* @param {string} height - CSS height value (e.g., '400px')
|
|
*/
|
|
setLegendHeight(height) {
|
|
// Store the custom height temporarily
|
|
this._customLegendHeight = height
|
|
},
|
|
|
|
/**
|
|
* Handle legend item click behavior
|
|
* - If at 90%: collapse to 30%
|
|
* - If at 30%: stay at 30%
|
|
* - If collapsed: stay collapsed
|
|
*/
|
|
handleLegendItemClick() {
|
|
if (this.legendState === 'fullyOpen') {
|
|
this.legendState = 'open' // 90% → 30%
|
|
}
|
|
// Else: do nothing (stay at current state)
|
|
},
|
|
|
|
/**
|
|
* Enable search mode (show ✕ icon)
|
|
*/
|
|
enableSearchMode() {
|
|
this.searchMode = true
|
|
},
|
|
|
|
/**
|
|
* Disable search mode (show ☰ icon)
|
|
*/
|
|
disableSearchMode() {
|
|
this.searchMode = false
|
|
},
|
|
|
|
/**
|
|
* Toggle hamburger menu (75% overlay)
|
|
*/
|
|
toggleHamburgerMenu() {
|
|
this.hamburgerMenuOpen = !this.hamburgerMenuOpen
|
|
},
|
|
|
|
/**
|
|
* Open hamburger menu
|
|
*/
|
|
openHamburgerMenu() {
|
|
this.hamburgerMenuOpen = true
|
|
},
|
|
|
|
/**
|
|
* Close hamburger menu
|
|
*/
|
|
closeHamburgerMenu() {
|
|
this.hamburgerMenuOpen = false
|
|
},
|
|
|
|
// ========================================
|
|
// Initialization
|
|
// ========================================
|
|
|
|
/**
|
|
* Initialize UI store
|
|
* Load saved preferences, set up listeners
|
|
*/
|
|
init() {
|
|
// Load theme from localStorage (sandboxed iframe throws — skip if unavailable)
|
|
let savedTheme = null;
|
|
try { savedTheme = localStorage.getItem('theme'); } catch (e) { /* sandboxed */ }
|
|
if (savedTheme) {
|
|
this.setTheme(savedTheme);
|
|
}
|
|
|
|
// Set up window resize listener
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
this.updateWindowSize(entry.contentRect.width, entry.contentRect.height);
|
|
}
|
|
});
|
|
resizeObserver.observe(document.documentElement);
|
|
|
|
// Listen for system theme changes
|
|
if (window.matchMedia) {
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
if (this.theme === 'auto') {
|
|
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
|
}
|
|
});
|
|
|
|
// Listen for reduced motion preference
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
|
|
this.reducedMotion = e.matches;
|
|
document.documentElement.setAttribute('data-reduced-motion', e.matches);
|
|
});
|
|
}
|
|
|
|
}
|
|
}
|
|
});
|
|
|
|
if (import.meta.hot) {
|
|
import.meta.hot.accept(acceptHMRUpdate(useUIStore, import.meta.hot));
|
|
}
|