Collapse the legend, tap a pin, then close the detail: the legend comes back open, though nothing asked it to. Reported against the leaf research map; reproduced at 1440 in en, collapsed-first. selectPeopleGroup saved the pre-detail legend state on every call that carried a group, not only on the call that entered detail. The second call therefore saved the 'open' the first call had just forced, on top of the 'collapsed' it correctly saved on entry, and closing the detail restored that 'open'. The measured part is why a second call happens at all. The pin click handler is registered per map layer, and every visible pin has an invisible hitbox twin at the same point -- same source, larger radius, added alongside it so taps are easier to land. A layer-scoped click fires once per layer with a feature under the point, so a single pin click calls selectPeopleGroup exactly twice, 3ms apart. Measured on two different pins, both reporting both layers at the click point; never more than twice. So ONE pick was already enough to lose the collapsed state -- it never needed two, which is why this reproduced every time rather than intermittently. The fix is to make the method idempotent: save only when entering detail (no group was selected before), restore only what was actually saved, then clear it so a later detail cannot inherit a stale value. The duplicate binding is a real defect in its own right and is tracked separately; this method should not have depended on being called exactly once. Verified two-sided against the shipped source, driving the real method body rather than a copy: seven scenarios pass on the fix and four fail with the previous body restored, while the scenarios that must be insensitive stay green both ways. The poisoned run reproduces the browser measurement value for value -- saved collapsed, overwritten to open by the second call, restored open. Trace in _qa-cycle so the next reader can re-run it.
1039 lines
32 KiB
JavaScript
1039 lines
32 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) {
|
|
// ★ SAVE ONLY ON ENTERING DETAIL, AND ONLY ONCE. This used to save on every call with a
|
|
// group, so a SECOND call while a detail was already open stored the 'open' this method
|
|
// had just forced, over the 'collapsed' it saved on entry — and the later clear restored
|
|
// 'open'. A reader who collapsed the legend, opened a detail and closed it got the
|
|
// legend back open, having never asked for it.
|
|
//
|
|
// ⚠ ONE PICK IS NOT ONE CALL — MEASURED, NOT ASSUMED. The pin click handler is
|
|
// registered per map layer, and every visible pin has an invisible hitbox twin at the
|
|
// same point (same source, larger radius, added alongside it) so that taps are easier
|
|
// to land. A layer-scoped click fires once per layer with a feature under the point,
|
|
// so EVERY pin click calls this exactly twice, 3ms apart — measured on the leaf at
|
|
// 1440 on 2026-09-29, on two different pins. One pick was therefore already enough to
|
|
// corrupt the saved state; it never needed two.
|
|
//
|
|
// Guarding on `entering` rather than on call count is what makes that harmless: this
|
|
// stays idempotent for as long as one detail is open, whatever the caller count. The
|
|
// duplicate binding is a real defect and is being fixed separately, but this method
|
|
// should not have depended on being called once in the first place.
|
|
const entering = peopleGroup && !this.selectedPeopleGroup;
|
|
this.selectedPeopleGroup = peopleGroup;
|
|
|
|
if (peopleGroup) {
|
|
if (entering) this._legendStateBeforeDetail = this.legendState;
|
|
this.legendState = 'open';
|
|
this._customLegendHeight = null;
|
|
this.setLegendMode('detail');
|
|
} else {
|
|
// Restore only what was actually saved, then forget it: a stale value left here
|
|
// would be applied to a later detail that never recorded one.
|
|
if (this._legendStateBeforeDetail) this.legendState = this._legendStateBeforeDetail;
|
|
this._legendStateBeforeDetail = null;
|
|
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));
|
|
}
|