doxa-maps/library/stores/uiStore.js
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

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));
}