diff --git a/src/modules/mobile-filter-logic.ts b/src/modules/mobile-filter-logic.ts index cf395ead..8672bd05 100644 --- a/src/modules/mobile-filter-logic.ts +++ b/src/modules/mobile-filter-logic.ts @@ -1,127 +1,2 @@ -// ======================================== -// Mobile Filter Logic Module -// State synchronization, filter counting, and badge updates -// ======================================== - -import { getState } from './store.ts'; -import { safeGetElementById, safeQuerySelector } from './utils.ts'; -import { saveFilterState } from './filter-state.ts'; -import type { FilterConfig } from './mobile-filter-sheet.ts'; - -type FilterInputElement = HTMLInputElement | HTMLSelectElement; - -/** - * Sync filter values from main filters to bottom sheet - */ -export function syncFiltersToSheet(): void { - const sheet = safeGetElementById('filter-bottom-sheet'); - if (!sheet) return; - - const sheetInputs = sheet.querySelectorAll('[data-filter-id]'); - - sheetInputs.forEach(sheetInput => { - const filterId = sheetInput.dataset.filterId; - if (!filterId) return; - - const mainInput = safeGetElementById(filterId) as FilterInputElement | null; - - if (mainInput) { - if (sheetInput instanceof HTMLInputElement && sheetInput.type === 'checkbox') { - sheetInput.checked = (mainInput as HTMLInputElement).checked; - } else if (sheetInput instanceof HTMLSelectElement && mainInput instanceof HTMLSelectElement) { - sheetInput.value = mainInput.value; - } - } - }); -} - -/** - * Apply filter values from bottom sheet to main filters - */ -export function applyFiltersFromSheet(): void { - const sheet = safeGetElementById('filter-bottom-sheet'); - if (!sheet) return; - - const sheetInputs = sheet.querySelectorAll('[data-filter-id]'); - - sheetInputs.forEach(sheetInput => { - const filterId = sheetInput.dataset.filterId; - if (!filterId) return; - - const mainInput = safeGetElementById(filterId) as FilterInputElement | null; - - if (mainInput) { - if (sheetInput instanceof HTMLInputElement && sheetInput.type === 'checkbox') { - (mainInput as HTMLInputElement).checked = sheetInput.checked; - } else if (sheetInput instanceof HTMLSelectElement && mainInput instanceof HTMLSelectElement) { - mainInput.value = sheetInput.value; - } - - mainInput.dispatchEvent(new Event('change', { bubbles: true })); - } - }); - - const currentTab = getState('currentTab'); - if (currentTab) { - saveFilterState(currentTab); - } -} - -/** - * Clear all filters in the bottom sheet - */ -export function clearSheetFilters(): void { - const sheet = safeGetElementById('filter-bottom-sheet'); - if (!sheet) return; - - const sheetInputs = sheet.querySelectorAll('[data-filter-id]'); - - sheetInputs.forEach(sheetInput => { - if (sheetInput instanceof HTMLInputElement && sheetInput.type === 'checkbox') { - sheetInput.checked = false; - } else if (sheetInput instanceof HTMLSelectElement) { - sheetInput.value = 'all'; - } - }); -} - -/** - * Count active filters - */ -export function countActiveFilters(tabFilters: Record): number { - let count = 0; - const currentTab = getState('currentTab'); - const filters = tabFilters[currentTab || ''] || []; - - filters.forEach(filter => { - const input = safeGetElementById(filter.id) as FilterInputElement | null; - if (!input) return; - - if (input instanceof HTMLInputElement && input.type === 'checkbox') { - if (input.checked) count++; - } else if (input instanceof HTMLSelectElement) { - if (input.value !== 'all' && input.value !== 'name' && input.value !== 'date_desc') { - count++; - } - } - }); - - return count; -} - -/** - * Update filter button badge - */ -export function updateFilterBadge(tabFilters: Record): void { - const btn = safeQuerySelector('.mobile-filter-btn') as HTMLElement | null; - if (!btn) return; - - const count = countActiveFilters(tabFilters); - const badge = btn.querySelector('.filter-badge'); - - if (badge) { - badge.textContent = count.toString(); - } - - btn.classList.toggle('has-filters', count > 0); -} +// SHIM — module moved to mobile/mobile-filter-logic.ts (remove after all imports updated) +export * from './mobile/mobile-filter-logic.ts'; diff --git a/src/modules/mobile-filter-sheet.ts b/src/modules/mobile-filter-sheet.ts index a12443e0..49809f96 100644 --- a/src/modules/mobile-filter-sheet.ts +++ b/src/modules/mobile-filter-sheet.ts @@ -1,154 +1,3 @@ -// ======================================== -// Mobile Filter Sheet DOM/UI Module -// Bottom sheet creation, rendering, and event listeners -// ======================================== - -import { safeGetElementById } from './utils.ts'; - -// ======================================== -// Types -// ======================================== - -interface FilterConfig { - id: string; - label: string; - type: 'select' | 'checkbox'; - options?: { value: string; label: string }[]; -} - -export type { FilterConfig }; - -/** - * Create the filter bottom sheet HTML - */ -export function createFilterSheet(_tabName: string, filters: FilterConfig[]): HTMLElement { - const sheet = document.createElement('div'); - sheet.id = 'filter-bottom-sheet'; - sheet.className = 'filter-bottom-sheet'; - sheet.setAttribute('role', 'dialog'); - sheet.setAttribute('aria-modal', 'true'); - sheet.setAttribute('aria-label', 'Filter options'); - - sheet.innerHTML = ` - -
- -
- Filters -
- - -
-
-
- ${renderFilterGroups(filters)} -
-
- -
-
- `; - - return sheet; -} - -/** - * Render filter groups HTML - */ -export function renderFilterGroups(filters: FilterConfig[]): string { - return filters - .map(filter => { - if (filter.type === 'checkbox') { - return ` -
- -
- `; - } else { - return ` -
- - -
- `; - } - }) - .join(''); -} - -/** - * Setup event listeners for the sheet - */ -export function setupSheetEventListeners( - sheet: HTMLElement, - hideFilterSheet: () => void, - clearSheetFilters: () => void, - applyFiltersFromSheet: () => void, - updateFilterBadge: () => void -): void { - const backdrop = sheet.querySelector('.filter-sheet-backdrop'); - backdrop?.addEventListener('click', hideFilterSheet); - - const closeBtn = sheet.querySelector('.filter-sheet-close'); - closeBtn?.addEventListener('click', hideFilterSheet); - - const clearBtn = sheet.querySelector('.filter-sheet-clear'); - clearBtn?.addEventListener('click', () => { - clearSheetFilters(); - }); - - const applyBtn = sheet.querySelector('#filter-sheet-apply-btn'); - applyBtn?.addEventListener('click', () => { - applyFiltersFromSheet(); - hideFilterSheet(); - updateFilterBadge(); - }); -} - -/** - * Handle keyboard navigation within sheet - */ -export function handleKeyboardNavigation(e: KeyboardEvent, isSheetOpen: boolean, hideFilterSheet: () => void): void { - if (!isSheetOpen) return; - - if (e.key === 'Escape') { - e.preventDefault(); - hideFilterSheet(); - } -} - -/** - * Handle focus trapping - */ -export function handleFocusTrap(e: KeyboardEvent, isSheetOpen: boolean): void { - if (e.key !== 'Tab' || !isSheetOpen) return; - - const sheet = safeGetElementById('filter-bottom-sheet'); - if (!sheet) return; - - const focusableElements = Array.from( - sheet.querySelectorAll('button:not([disabled]), select, input, [tabindex]:not([tabindex="-1"])') - ).filter(el => el.offsetParent !== null); - - if (focusableElements.length === 0) return; - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - - if (e.shiftKey) { - if (document.activeElement === firstElement) { - e.preventDefault(); - lastElement?.focus(); - } - } else if (document.activeElement === lastElement) { - e.preventDefault(); - firstElement?.focus(); - } -} +// SHIM — module moved to mobile/mobile-filter-sheet.ts (remove after all imports updated) +export * from './mobile/mobile-filter-sheet.ts'; +export type { FilterConfig } from './mobile/mobile-filter-sheet.ts'; diff --git a/src/modules/mobile-filters.ts b/src/modules/mobile-filters.ts index a94e7bf1..61066789 100644 --- a/src/modules/mobile-filters.ts +++ b/src/modules/mobile-filters.ts @@ -1,406 +1,2 @@ -// ======================================== -// Mobile Filter Bottom Sheet Module -// Thin entry point - orchestrates sheet UI and filter logic -// ======================================== - -import { getState, subscribe } from './store.ts'; -import { safeGetElementById, safeQuerySelector } from './utils.ts'; -import { logger } from './logger.ts'; - -// Import from sub-modules -import { - createFilterSheet, - renderFilterGroups, - setupSheetEventListeners, - handleKeyboardNavigation, - handleFocusTrap, - type FilterConfig, -} from './mobile-filter-sheet.ts'; -import { - syncFiltersToSheet, - applyFiltersFromSheet, - clearSheetFilters, - updateFilterBadge as _updateFilterBadge, -} from './mobile-filter-logic.ts'; - -// ======================================== -// Filter Configurations per Tab -// ======================================== - -type TabFilters = Record; - -const TAB_FILTERS: TabFilters = { - items: [ - { - id: 'favoritesOnly', - label: '⭐ Favorites Only', - type: 'checkbox', - }, - { - id: 'rarityFilter', - label: 'Rarity', - type: 'select', - options: [ - { value: 'all', label: 'All Rarities' }, - { value: 'common', label: 'Common' }, - { value: 'uncommon', label: 'Uncommon' }, - { value: 'rare', label: 'Rare' }, - { value: 'epic', label: 'Epic' }, - { value: 'legendary', label: 'Legendary' }, - ], - }, - { - id: 'tierFilter', - label: 'Tier', - type: 'select', - options: [ - { value: 'all', label: 'All Tiers' }, - { value: 'SS', label: 'SS Tier' }, - { value: 'S', label: 'S Tier' }, - { value: 'A', label: 'A Tier' }, - { value: 'B', label: 'B Tier' }, - { value: 'C', label: 'C Tier' }, - ], - }, - { - id: 'stackingFilter', - label: 'Stacking', - type: 'select', - options: [ - { value: 'all', label: 'All' }, - { value: 'stacks_well', label: 'Stacks Well' }, - { value: 'one_and_done', label: 'One-and-Done' }, - ], - }, - { - id: 'sortBy', - label: 'Sort By', - type: 'select', - options: [ - { value: 'name', label: 'Name' }, - { value: 'tier', label: 'Tier' }, - { value: 'rarity', label: 'Rarity' }, - ], - }, - ], - weapons: [ - { - id: 'favoritesOnly', - label: '⭐ Favorites Only', - type: 'checkbox', - }, - { - id: 'tierFilter', - label: 'Tier', - type: 'select', - options: [ - { value: 'all', label: 'All Tiers' }, - { value: 'SS', label: 'SS Tier' }, - { value: 'S', label: 'S Tier' }, - { value: 'A', label: 'A Tier' }, - { value: 'B', label: 'B Tier' }, - { value: 'C', label: 'C Tier' }, - ], - }, - { - id: 'sortBy', - label: 'Sort By', - type: 'select', - options: [ - { value: 'name', label: 'Name' }, - { value: 'tier', label: 'Tier' }, - ], - }, - ], - tomes: [ - { - id: 'favoritesOnly', - label: '⭐ Favorites Only', - type: 'checkbox', - }, - { - id: 'tierFilter', - label: 'Tier', - type: 'select', - options: [ - { value: 'all', label: 'All Tiers' }, - { value: 'SS', label: 'SS Tier' }, - { value: 'S', label: 'S Tier' }, - { value: 'A', label: 'A Tier' }, - { value: 'B', label: 'B Tier' }, - { value: 'C', label: 'C Tier' }, - ], - }, - { - id: 'sortBy', - label: 'Sort By', - type: 'select', - options: [ - { value: 'name', label: 'Name' }, - { value: 'tier', label: 'Tier' }, - ], - }, - ], - characters: [ - { - id: 'favoritesOnly', - label: '⭐ Favorites Only', - type: 'checkbox', - }, - { - id: 'tierFilter', - label: 'Tier', - type: 'select', - options: [ - { value: 'all', label: 'All Tiers' }, - { value: 'SS', label: 'SS Tier' }, - { value: 'S', label: 'S Tier' }, - { value: 'A', label: 'A Tier' }, - { value: 'B', label: 'B Tier' }, - { value: 'C', label: 'C Tier' }, - ], - }, - { - id: 'sortBy', - label: 'Sort By', - type: 'select', - options: [ - { value: 'name', label: 'Name' }, - { value: 'tier', label: 'Tier' }, - ], - }, - ], - shrines: [ - { - id: 'favoritesOnly', - label: '⭐ Favorites Only', - type: 'checkbox', - }, - { - id: 'typeFilter', - label: 'Type', - type: 'select', - options: [ - { value: 'all', label: 'All Types' }, - { value: 'stat_upgrade', label: 'Stat Upgrade' }, - { value: 'combat', label: 'Combat' }, - { value: 'utility', label: 'Utility' }, - { value: 'risk_reward', label: 'Risk/Reward' }, - ], - }, - ], - changelog: [ - { - id: 'categoryFilter', - label: 'Category', - type: 'select', - options: [ - { value: 'all', label: 'All Categories' }, - { value: 'balance', label: 'Balance Changes' }, - { value: 'new_content', label: 'New Content' }, - { value: 'bug_fixes', label: 'Bug Fixes' }, - { value: 'removed', label: 'Removed' }, - { value: 'other', label: 'Other' }, - ], - }, - { - id: 'sortBy', - label: 'Sort By', - type: 'select', - options: [ - { value: 'date_desc', label: 'Newest First' }, - { value: 'date_asc', label: 'Oldest First' }, - ], - }, - ], -}; - -// ======================================== -// State -// ======================================== - -let isSheetOpen = false; -let previouslyFocusedElement: HTMLElement | null = null; - -// ======================================== -// Show/Hide Logic -// ======================================== - -/** - * Show the filter bottom sheet - */ -export function showFilterSheet(): void { - previouslyFocusedElement = document.activeElement as HTMLElement; - - const currentTab = getState('currentTab') || 'items'; - let sheet = safeGetElementById('filter-bottom-sheet'); - - if (!TAB_FILTERS[currentTab]) { - logger.debug({ - operation: 'mobile-filters.show', - data: { tab: currentTab, reason: 'no_filters_for_tab' }, - }); - return; - } - - if (sheet) { - const content = sheet.querySelector('#filter-sheet-content'); - if (content) { - const filters = TAB_FILTERS[currentTab] || []; - content.innerHTML = renderFilterGroups(filters); - } - } else { - const filters = TAB_FILTERS[currentTab] || []; - sheet = createFilterSheet(currentTab, filters); - document.body.appendChild(sheet); - setupSheetEventListeners(sheet, hideFilterSheet, clearSheetFilters, applyFiltersFromSheet, updateFilterBadge); - } - - syncFiltersToSheet(); - - isSheetOpen = true; - sheet.classList.add('active'); - document.body.classList.add('filter-sheet-open'); - - document.addEventListener('keydown', _handleKeyboardNavigation); - document.addEventListener('keydown', _handleFocusTrap); - - requestAnimationFrame(() => { - const firstInput = sheet?.querySelector('select, input'); - firstInput?.focus(); - }); - - logger.debug({ - operation: 'mobile-filters.sheet', - data: { action: 'open', tab: currentTab }, - }); -} - -/** - * Hide the filter bottom sheet - */ -export function hideFilterSheet(): void { - const sheet = safeGetElementById('filter-bottom-sheet'); - if (!sheet) return; - - isSheetOpen = false; - sheet.classList.remove('active'); - document.body.classList.remove('filter-sheet-open'); - - document.removeEventListener('keydown', _handleKeyboardNavigation); - document.removeEventListener('keydown', _handleFocusTrap); - - if (previouslyFocusedElement) { - previouslyFocusedElement.focus(); - previouslyFocusedElement = null; - } - - logger.debug({ - operation: 'mobile-filters.sheet', - data: { action: 'close' }, - }); -} - -/** - * Toggle the filter bottom sheet - */ -export function toggleFilterSheet(): void { - if (isSheetOpen) { - hideFilterSheet(); - } else { - showFilterSheet(); - } -} - -// Wrapper functions for keyboard handlers that pass current state -function _handleKeyboardNavigation(e: KeyboardEvent): void { - handleKeyboardNavigation(e, isSheetOpen, hideFilterSheet); -} - -function _handleFocusTrap(e: KeyboardEvent): void { - handleFocusTrap(e, isSheetOpen); -} - -/** - * Update filter badge (bound to TAB_FILTERS) - */ -export function updateFilterBadge(): void { - _updateFilterBadge(TAB_FILTERS); -} - -/** - * Create the mobile filter button - */ -function createMobileFilterButton(): HTMLElement { - const btn = document.createElement('button'); - btn.className = 'mobile-filter-btn'; - btn.type = 'button'; - btn.setAttribute('aria-label', 'Open filters'); - btn.setAttribute('aria-haspopup', 'dialog'); - btn.setAttribute('aria-expanded', 'false'); - - btn.innerHTML = ` - - Filters - - `; - - btn.addEventListener('click', () => { - toggleFilterSheet(); - btn.setAttribute('aria-expanded', isSheetOpen ? 'true' : 'false'); - }); - - return btn; -} - -/** - * Inject the mobile filter button into the controls - */ -function injectMobileFilterButton(): void { - const controls = safeQuerySelector('.controls .container'); - if (!controls) return; - - if (safeQuerySelector('.mobile-filter-btn')) return; - - const btn = createMobileFilterButton(); - - const searchBox = controls.querySelector('.search-box'); - if (searchBox) { - searchBox.after(btn); - } else { - controls.appendChild(btn); - } -} - -// ======================================== -// Initialization -// ======================================== - -/** - * Initialize mobile filters - */ -export function initMobileFilters(): void { - injectMobileFilterButton(); - - subscribe('currentTab', () => { - setTimeout(() => { - updateFilterBadge(); - }, 100); - }); - - const filtersContainer = safeGetElementById('filters'); - if (filtersContainer) { - filtersContainer.addEventListener('change', () => { - updateFilterBadge(); - }); - } - - updateFilterBadge(); - - logger.info({ - operation: 'mobile-filters.init', - data: { status: 'initialized' }, - }); -} - -// Export for external use +// SHIM — module moved to mobile/mobile-filters.ts (remove after all imports updated) +export * from './mobile/mobile-filters.ts'; diff --git a/src/modules/mobile-nav.ts b/src/modules/mobile-nav.ts index 34fe790e..78143132 100644 --- a/src/modules/mobile-nav.ts +++ b/src/modules/mobile-nav.ts @@ -1,459 +1,2 @@ -// ======================================== -// Mobile Bottom Navigation Module -// ======================================== -// Provides a thumb-friendly bottom navigation for mobile users -// with an accessible slide-up "More" drawer -// ======================================== - -import { getState, setState, subscribe, type TabName } from './store.ts'; -import { safeGetElementById, safeQuerySelector, safeQuerySelectorAll } from './utils.ts'; -import { logger } from './logger.ts'; - -// ======================================== -// Types -// ======================================== - -interface MoreMenuConfig { - tab: string; - label: string; - icon: string; -} - -// ======================================== -// Constants -// ======================================== - -const MORE_MENU_TABS: MoreMenuConfig[] = [ - { tab: 'advisor', label: 'Advisor', icon: '🤖' }, - { tab: 'characters', label: 'Characters', icon: '👤' }, - { tab: 'shrines', label: 'Shrines', icon: '⛩️' }, - { tab: 'calculator', label: 'Calculator', icon: '🧮' }, - { tab: 'changelog', label: 'Changelog', icon: '📋' }, - { tab: 'about', label: 'About', icon: 'ℹ️' }, -]; - -const MORE_TAB_NAMES = new Set(MORE_MENU_TABS.map(t => t.tab)); - -// ======================================== -// State -// ======================================== - -let isMenuOpen = false; -let previouslyFocusedElement: HTMLElement | null = null; -let focusableElements: HTMLElement[] = []; - -// ======================================== -// More Menu Component -// ======================================== - -/** - * Create the "More" menu drawer HTML - */ -function createMoreMenu(): HTMLElement { - const menu = document.createElement('div'); - menu.id = 'more-menu'; - menu.className = 'more-menu'; - menu.setAttribute('role', 'dialog'); - menu.setAttribute('aria-modal', 'true'); - menu.setAttribute('aria-label', 'Additional navigation tabs'); - - const currentTab = getState('currentTab'); - - // SAFE: tab values are from hardcoded MORE_MENU_TABS constant - menu.innerHTML = ` - -
- -
- More Options - -
- -
- `; - - return menu; -} - -/** - * Update menu items to reflect current tab - */ -function updateMenuItems(currentTab: string): void { - const menu = safeGetElementById('more-menu'); - if (!menu) return; - - const items = menu.querySelectorAll('.more-menu-item'); - items.forEach(item => { - const btn = item as HTMLElement; - const tab = btn.dataset.tab; - const isCurrent = tab === currentTab; - - btn.classList.toggle('current', isCurrent); - btn.setAttribute('aria-current', isCurrent ? 'page' : 'false'); - }); -} - -/** - * Get all focusable elements within the menu - */ -function getFocusableElements(): HTMLElement[] { - const menu = safeGetElementById('more-menu'); - if (!menu) return []; - - return Array.from( - menu.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])') - ).filter(el => el.offsetParent !== null); // Filter out hidden elements -} - -/** - * Trap focus within the menu - */ -function handleFocusTrap(e: KeyboardEvent): void { - if (e.key !== 'Tab' || !isMenuOpen) return; - - focusableElements = getFocusableElements(); - if (focusableElements.length === 0) return; - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - - if (e.shiftKey) { - // Shift + Tab: moving backwards - if (document.activeElement === firstElement) { - e.preventDefault(); - lastElement?.focus(); - } - } else if (document.activeElement === lastElement) { - // Tab: moving forwards - e.preventDefault(); - firstElement?.focus(); - } -} - -/** - * Handle keyboard navigation within menu - */ -function handleKeyboardNavigation(e: KeyboardEvent): void { - if (!isMenuOpen) return; - - const menu = safeGetElementById('more-menu'); - if (!menu) return; - - switch (e.key) { - case 'Escape': - e.preventDefault(); - hideMoreMenu(); - break; - - case 'ArrowDown': - case 'ArrowRight': { - e.preventDefault(); - const items = Array.from(menu.querySelectorAll('.more-menu-item')); - const currentIndex = items.indexOf(document.activeElement as HTMLElement); - const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; - items[nextIndex]?.focus(); - break; - } - - case 'ArrowUp': - case 'ArrowLeft': { - e.preventDefault(); - const items = Array.from(menu.querySelectorAll('.more-menu-item')); - const currentIndex = items.indexOf(document.activeElement as HTMLElement); - const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; - items[prevIndex]?.focus(); - break; - } - - case 'Home': { - e.preventDefault(); - const items = menu.querySelectorAll('.more-menu-item'); - (items[0] as HTMLElement)?.focus(); - break; - } - - case 'End': { - e.preventDefault(); - const items = menu.querySelectorAll('.more-menu-item'); - (items[items.length - 1] as HTMLElement)?.focus(); - break; - } - } -} - -/** - * Show the more menu with animation - */ -function showMoreMenu(): void { - // Save the currently focused element to restore later - previouslyFocusedElement = document.activeElement as HTMLElement; - - let menu = safeGetElementById('more-menu'); - - if (menu) { - // Update items in case current tab changed - const currentTab = getState('currentTab'); - if (currentTab) { - updateMenuItems(currentTab); - } - } else { - menu = createMoreMenu(); - document.body.appendChild(menu); - setupMenuEventListeners(menu); - } - - isMenuOpen = true; - menu.classList.add('active'); - document.body.classList.add('more-menu-open'); - - // Add keyboard event listeners - document.addEventListener('keydown', handleKeyboardNavigation); - document.addEventListener('keydown', handleFocusTrap); - - // Focus the first menu item after animation - requestAnimationFrame(() => { - const firstItem = menu?.querySelector('.more-menu-item'); - firstItem?.focus(); - }); - - logger.debug({ - operation: 'mobile-nav.more-menu', - data: { action: 'open' }, - }); -} - -/** - * Hide the more menu - */ -function hideMoreMenu(): void { - const menu = safeGetElementById('more-menu'); - if (!menu) return; - - isMenuOpen = false; - menu.classList.remove('active'); - document.body.classList.remove('more-menu-open'); - - // Remove keyboard event listeners - document.removeEventListener('keydown', handleKeyboardNavigation); - document.removeEventListener('keydown', handleFocusTrap); - - // Restore focus to the previously focused element (the More button) - if (previouslyFocusedElement) { - previouslyFocusedElement.focus(); - previouslyFocusedElement = null; - } - - logger.debug({ - operation: 'mobile-nav.more-menu', - data: { action: 'close' }, - }); -} - -/** - * Toggle the more menu - */ -function toggleMoreMenu(): void { - if (isMenuOpen) { - hideMoreMenu(); - } else { - showMoreMenu(); - } -} - -/** - * Setup event listeners for the menu - */ -function setupMenuEventListeners(menu: HTMLElement): void { - // Backdrop click to close - const backdrop = menu.querySelector('.more-menu-backdrop'); - backdrop?.addEventListener('click', hideMoreMenu); - - // Close button click - const closeBtn = menu.querySelector('.more-menu-close'); - closeBtn?.addEventListener('click', hideMoreMenu); - - // Menu item clicks - use event delegation - const itemsContainer = menu.querySelector('.more-menu-items'); - itemsContainer?.addEventListener('click', (e: Event) => { - const target = e.target as HTMLElement; - const item = target.closest('.more-menu-item'); - - if (item) { - const tab = item.dataset.tab; - if (tab) { - switchTab(tab); - hideMoreMenu(); - } - } - }); - - // Handle Enter/Space on menu items for keyboard users - itemsContainer?.addEventListener('keydown', (e: Event) => { - const keyEvent = e as KeyboardEvent; - if (keyEvent.key === 'Enter' || keyEvent.key === ' ') { - const target = keyEvent.target as HTMLElement; - if (target.classList.contains('more-menu-item')) { - keyEvent.preventDefault(); - const tab = target.dataset.tab; - if (tab) { - switchTab(tab); - hideMoreMenu(); - } - } - } - }); -} - -// ======================================== -// Tab Switching -// ======================================== - -/** - * Switch to a tab (triggers existing tab system) - */ -function switchTab(tabName: string): void { - // Find and click the corresponding tab button in the main nav - const tabBtn = safeQuerySelector(`.tab-btn[data-tab="${tabName}"]`) as HTMLElement | null; - - if (tabBtn) { - tabBtn.click(); - } else { - // Fallback: directly set state and trigger render - setState('currentTab', tabName as TabName); - } -} - -/** - * Update mobile nav active state based on current tab - */ -function updateMobileNavState(currentTab: string): void { - const navItems = safeQuerySelectorAll('.mobile-bottom-nav .nav-item'); - - // Check if current tab is in the "more" menu - const isMoreTab = MORE_TAB_NAMES.has(currentTab); - const currentMoreTab = isMoreTab ? MORE_MENU_TABS.find(t => t.tab === currentTab) : null; - - navItems.forEach(item => { - const btn = item as HTMLElement; - const tab = btn.dataset.tab; - - if (tab === 'more') { - // Update the More button to show current tab if viewing a More tab - const iconSpan = btn.querySelector('.nav-icon'); - const labelSpan = btn.querySelector('span:not(.nav-icon)'); - - if (isMoreTab && currentMoreTab) { - // Show current tab's icon and label - if (iconSpan) iconSpan.textContent = currentMoreTab.icon; - if (labelSpan) labelSpan.textContent = currentMoreTab.label; - btn.classList.add('active'); - btn.setAttribute('aria-label', `${currentMoreTab.label} (tap for more options)`); - } else { - // Reset to default More button - if (iconSpan) iconSpan.textContent = '≡'; - if (labelSpan) labelSpan.textContent = 'More'; - btn.classList.remove('active'); - btn.setAttribute('aria-label', 'More tabs'); - } - btn.setAttribute('aria-expanded', 'false'); - } else if (tab === currentTab) { - btn.classList.add('active'); - } else { - btn.classList.remove('active'); - } - }); - - // Also update menu items if menu exists - updateMenuItems(currentTab); -} - -// ======================================== -// Event Handlers -// ======================================== - -/** - * Handle mobile nav item clicks - */ -function handleNavClick(e: Event): void { - const target = e.target as HTMLElement; - const navItem = target.closest('.nav-item'); - - if (!navItem) return; - - const tab = navItem.dataset.tab; - - if (tab === 'more') { - toggleMoreMenu(); - } else if (tab) { - // Close menu if open when switching to a main nav tab - if (isMenuOpen) { - hideMoreMenu(); - } - switchTab(tab); - } -} - -// ======================================== -// Initialization -// ======================================== - -/** - * Initialize mobile bottom navigation - */ -export function initMobileNav(): void { - const mobileNav = safeQuerySelector('.mobile-bottom-nav'); - - if (!mobileNav) { - logger.warn({ - operation: 'mobile-nav.init', - data: { reason: 'mobile_nav_not_found' }, - }); - return; - } - - // Add click listener using event delegation - mobileNav.addEventListener('click', handleNavClick); - - // Setup aria-expanded on the More button - const moreBtn = mobileNav.querySelector('[data-tab="more"]'); - if (moreBtn) { - moreBtn.setAttribute('aria-expanded', 'false'); - moreBtn.setAttribute('aria-haspopup', 'dialog'); - } - - // Subscribe to tab changes to update nav state - subscribe('currentTab', newTab => { - updateMobileNavState(newTab as string); - }); - - // Initialize with current tab - const currentTab = getState('currentTab'); - if (currentTab) { - updateMobileNavState(currentTab as string); - } - - logger.info({ - operation: 'mobile-nav.init', - data: { status: 'initialized' }, - }); -} - -// Export for external use -export { hideMoreMenu, showMoreMenu, toggleMoreMenu }; +// SHIM — module moved to mobile/mobile-nav.ts (remove after all imports updated) +export * from './mobile/mobile-nav.ts'; diff --git a/src/modules/mobile/index.ts b/src/modules/mobile/index.ts new file mode 100644 index 00000000..fad84ea8 --- /dev/null +++ b/src/modules/mobile/index.ts @@ -0,0 +1,32 @@ +// ======================================== +// Mobile Module Barrel +// ======================================== +// Re-exports all mobile-specific functionality + +export { initMobileNav, hideMoreMenu, showMoreMenu, toggleMoreMenu } from './mobile-nav.ts'; + +export { + showFilterSheet, + hideFilterSheet, + toggleFilterSheet, + updateFilterBadge, + initMobileFilters, +} from './mobile-filters.ts'; + +export { PULL_REFRESH_CONFIG, initPullRefresh, cleanupPullRefresh } from './pull-refresh.ts'; + +export type { FilterConfig } from './mobile-filter-sheet.ts'; +export { + createFilterSheet, + renderFilterGroups, + setupSheetEventListeners, + handleKeyboardNavigation, + handleFocusTrap, +} from './mobile-filter-sheet.ts'; + +export { + syncFiltersToSheet, + applyFiltersFromSheet, + clearSheetFilters, + countActiveFilters, +} from './mobile-filter-logic.ts'; diff --git a/src/modules/mobile/mobile-filter-logic.ts b/src/modules/mobile/mobile-filter-logic.ts new file mode 100644 index 00000000..8fb49e1c --- /dev/null +++ b/src/modules/mobile/mobile-filter-logic.ts @@ -0,0 +1,127 @@ +// ======================================== +// Mobile Filter Logic Module +// State synchronization, filter counting, and badge updates +// ======================================== + +import { getState } from '../store.ts'; +import { safeGetElementById, safeQuerySelector } from '../utils.ts'; +import { saveFilterState } from '../filter-state.ts'; +import type { FilterConfig } from './mobile-filter-sheet.ts'; + +type FilterInputElement = HTMLInputElement | HTMLSelectElement; + +/** + * Sync filter values from main filters to bottom sheet + */ +export function syncFiltersToSheet(): void { + const sheet = safeGetElementById('filter-bottom-sheet'); + if (!sheet) return; + + const sheetInputs = sheet.querySelectorAll('[data-filter-id]'); + + sheetInputs.forEach(sheetInput => { + const filterId = sheetInput.dataset.filterId; + if (!filterId) return; + + const mainInput = safeGetElementById(filterId) as FilterInputElement | null; + + if (mainInput) { + if (sheetInput instanceof HTMLInputElement && sheetInput.type === 'checkbox') { + sheetInput.checked = (mainInput as HTMLInputElement).checked; + } else if (sheetInput instanceof HTMLSelectElement && mainInput instanceof HTMLSelectElement) { + sheetInput.value = mainInput.value; + } + } + }); +} + +/** + * Apply filter values from bottom sheet to main filters + */ +export function applyFiltersFromSheet(): void { + const sheet = safeGetElementById('filter-bottom-sheet'); + if (!sheet) return; + + const sheetInputs = sheet.querySelectorAll('[data-filter-id]'); + + sheetInputs.forEach(sheetInput => { + const filterId = sheetInput.dataset.filterId; + if (!filterId) return; + + const mainInput = safeGetElementById(filterId) as FilterInputElement | null; + + if (mainInput) { + if (sheetInput instanceof HTMLInputElement && sheetInput.type === 'checkbox') { + (mainInput as HTMLInputElement).checked = sheetInput.checked; + } else if (sheetInput instanceof HTMLSelectElement && mainInput instanceof HTMLSelectElement) { + mainInput.value = sheetInput.value; + } + + mainInput.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + + const currentTab = getState('currentTab'); + if (currentTab) { + saveFilterState(currentTab); + } +} + +/** + * Clear all filters in the bottom sheet + */ +export function clearSheetFilters(): void { + const sheet = safeGetElementById('filter-bottom-sheet'); + if (!sheet) return; + + const sheetInputs = sheet.querySelectorAll('[data-filter-id]'); + + sheetInputs.forEach(sheetInput => { + if (sheetInput instanceof HTMLInputElement && sheetInput.type === 'checkbox') { + sheetInput.checked = false; + } else if (sheetInput instanceof HTMLSelectElement) { + sheetInput.value = 'all'; + } + }); +} + +/** + * Count active filters + */ +export function countActiveFilters(tabFilters: Record): number { + let count = 0; + const currentTab = getState('currentTab'); + const filters = tabFilters[currentTab || ''] || []; + + filters.forEach(filter => { + const input = safeGetElementById(filter.id) as FilterInputElement | null; + if (!input) return; + + if (input instanceof HTMLInputElement && input.type === 'checkbox') { + if (input.checked) count++; + } else if (input instanceof HTMLSelectElement) { + if (input.value !== 'all' && input.value !== 'name' && input.value !== 'date_desc') { + count++; + } + } + }); + + return count; +} + +/** + * Update filter button badge + */ +export function updateFilterBadge(tabFilters: Record): void { + const btn = safeQuerySelector('.mobile-filter-btn') as HTMLElement | null; + if (!btn) return; + + const count = countActiveFilters(tabFilters); + const badge = btn.querySelector('.filter-badge'); + + if (badge) { + badge.textContent = count.toString(); + } + + btn.classList.toggle('has-filters', count > 0); +} diff --git a/src/modules/mobile/mobile-filter-sheet.ts b/src/modules/mobile/mobile-filter-sheet.ts new file mode 100644 index 00000000..293c93d4 --- /dev/null +++ b/src/modules/mobile/mobile-filter-sheet.ts @@ -0,0 +1,154 @@ +// ======================================== +// Mobile Filter Sheet DOM/UI Module +// Bottom sheet creation, rendering, and event listeners +// ======================================== + +import { safeGetElementById } from '../utils.ts'; + +// ======================================== +// Types +// ======================================== + +interface FilterConfig { + id: string; + label: string; + type: 'select' | 'checkbox'; + options?: { value: string; label: string }[]; +} + +export type { FilterConfig }; + +/** + * Create the filter bottom sheet HTML + */ +export function createFilterSheet(_tabName: string, filters: FilterConfig[]): HTMLElement { + const sheet = document.createElement('div'); + sheet.id = 'filter-bottom-sheet'; + sheet.className = 'filter-bottom-sheet'; + sheet.setAttribute('role', 'dialog'); + sheet.setAttribute('aria-modal', 'true'); + sheet.setAttribute('aria-label', 'Filter options'); + + sheet.innerHTML = ` + +
+ +
+ Filters +
+ + +
+
+
+ ${renderFilterGroups(filters)} +
+
+ +
+
+ `; + + return sheet; +} + +/** + * Render filter groups HTML + */ +export function renderFilterGroups(filters: FilterConfig[]): string { + return filters + .map(filter => { + if (filter.type === 'checkbox') { + return ` +
+ +
+ `; + } else { + return ` +
+ + +
+ `; + } + }) + .join(''); +} + +/** + * Setup event listeners for the sheet + */ +export function setupSheetEventListeners( + sheet: HTMLElement, + hideFilterSheet: () => void, + clearSheetFilters: () => void, + applyFiltersFromSheet: () => void, + updateFilterBadge: () => void +): void { + const backdrop = sheet.querySelector('.filter-sheet-backdrop'); + backdrop?.addEventListener('click', hideFilterSheet); + + const closeBtn = sheet.querySelector('.filter-sheet-close'); + closeBtn?.addEventListener('click', hideFilterSheet); + + const clearBtn = sheet.querySelector('.filter-sheet-clear'); + clearBtn?.addEventListener('click', () => { + clearSheetFilters(); + }); + + const applyBtn = sheet.querySelector('#filter-sheet-apply-btn'); + applyBtn?.addEventListener('click', () => { + applyFiltersFromSheet(); + hideFilterSheet(); + updateFilterBadge(); + }); +} + +/** + * Handle keyboard navigation within sheet + */ +export function handleKeyboardNavigation(e: KeyboardEvent, isSheetOpen: boolean, hideFilterSheet: () => void): void { + if (!isSheetOpen) return; + + if (e.key === 'Escape') { + e.preventDefault(); + hideFilterSheet(); + } +} + +/** + * Handle focus trapping + */ +export function handleFocusTrap(e: KeyboardEvent, isSheetOpen: boolean): void { + if (e.key !== 'Tab' || !isSheetOpen) return; + + const sheet = safeGetElementById('filter-bottom-sheet'); + if (!sheet) return; + + const focusableElements = Array.from( + sheet.querySelectorAll('button:not([disabled]), select, input, [tabindex]:not([tabindex="-1"])') + ).filter(el => el.offsetParent !== null); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement?.focus(); + } + } else if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement?.focus(); + } +} diff --git a/src/modules/mobile/mobile-filters.ts b/src/modules/mobile/mobile-filters.ts new file mode 100644 index 00000000..d4c63a2c --- /dev/null +++ b/src/modules/mobile/mobile-filters.ts @@ -0,0 +1,406 @@ +// ======================================== +// Mobile Filter Bottom Sheet Module +// Thin entry point - orchestrates sheet UI and filter logic +// ======================================== + +import { getState, subscribe } from '../store.ts'; +import { safeGetElementById, safeQuerySelector } from '../utils.ts'; +import { logger } from '../logger.ts'; + +// Import from sub-modules +import { + createFilterSheet, + renderFilterGroups, + setupSheetEventListeners, + handleKeyboardNavigation, + handleFocusTrap, + type FilterConfig, +} from './mobile-filter-sheet.ts'; +import { + syncFiltersToSheet, + applyFiltersFromSheet, + clearSheetFilters, + updateFilterBadge as _updateFilterBadge, +} from './mobile-filter-logic.ts'; + +// ======================================== +// Filter Configurations per Tab +// ======================================== + +type TabFilters = Record; + +const TAB_FILTERS: TabFilters = { + items: [ + { + id: 'favoritesOnly', + label: '⭐ Favorites Only', + type: 'checkbox', + }, + { + id: 'rarityFilter', + label: 'Rarity', + type: 'select', + options: [ + { value: 'all', label: 'All Rarities' }, + { value: 'common', label: 'Common' }, + { value: 'uncommon', label: 'Uncommon' }, + { value: 'rare', label: 'Rare' }, + { value: 'epic', label: 'Epic' }, + { value: 'legendary', label: 'Legendary' }, + ], + }, + { + id: 'tierFilter', + label: 'Tier', + type: 'select', + options: [ + { value: 'all', label: 'All Tiers' }, + { value: 'SS', label: 'SS Tier' }, + { value: 'S', label: 'S Tier' }, + { value: 'A', label: 'A Tier' }, + { value: 'B', label: 'B Tier' }, + { value: 'C', label: 'C Tier' }, + ], + }, + { + id: 'stackingFilter', + label: 'Stacking', + type: 'select', + options: [ + { value: 'all', label: 'All' }, + { value: 'stacks_well', label: 'Stacks Well' }, + { value: 'one_and_done', label: 'One-and-Done' }, + ], + }, + { + id: 'sortBy', + label: 'Sort By', + type: 'select', + options: [ + { value: 'name', label: 'Name' }, + { value: 'tier', label: 'Tier' }, + { value: 'rarity', label: 'Rarity' }, + ], + }, + ], + weapons: [ + { + id: 'favoritesOnly', + label: '⭐ Favorites Only', + type: 'checkbox', + }, + { + id: 'tierFilter', + label: 'Tier', + type: 'select', + options: [ + { value: 'all', label: 'All Tiers' }, + { value: 'SS', label: 'SS Tier' }, + { value: 'S', label: 'S Tier' }, + { value: 'A', label: 'A Tier' }, + { value: 'B', label: 'B Tier' }, + { value: 'C', label: 'C Tier' }, + ], + }, + { + id: 'sortBy', + label: 'Sort By', + type: 'select', + options: [ + { value: 'name', label: 'Name' }, + { value: 'tier', label: 'Tier' }, + ], + }, + ], + tomes: [ + { + id: 'favoritesOnly', + label: '⭐ Favorites Only', + type: 'checkbox', + }, + { + id: 'tierFilter', + label: 'Tier', + type: 'select', + options: [ + { value: 'all', label: 'All Tiers' }, + { value: 'SS', label: 'SS Tier' }, + { value: 'S', label: 'S Tier' }, + { value: 'A', label: 'A Tier' }, + { value: 'B', label: 'B Tier' }, + { value: 'C', label: 'C Tier' }, + ], + }, + { + id: 'sortBy', + label: 'Sort By', + type: 'select', + options: [ + { value: 'name', label: 'Name' }, + { value: 'tier', label: 'Tier' }, + ], + }, + ], + characters: [ + { + id: 'favoritesOnly', + label: '⭐ Favorites Only', + type: 'checkbox', + }, + { + id: 'tierFilter', + label: 'Tier', + type: 'select', + options: [ + { value: 'all', label: 'All Tiers' }, + { value: 'SS', label: 'SS Tier' }, + { value: 'S', label: 'S Tier' }, + { value: 'A', label: 'A Tier' }, + { value: 'B', label: 'B Tier' }, + { value: 'C', label: 'C Tier' }, + ], + }, + { + id: 'sortBy', + label: 'Sort By', + type: 'select', + options: [ + { value: 'name', label: 'Name' }, + { value: 'tier', label: 'Tier' }, + ], + }, + ], + shrines: [ + { + id: 'favoritesOnly', + label: '⭐ Favorites Only', + type: 'checkbox', + }, + { + id: 'typeFilter', + label: 'Type', + type: 'select', + options: [ + { value: 'all', label: 'All Types' }, + { value: 'stat_upgrade', label: 'Stat Upgrade' }, + { value: 'combat', label: 'Combat' }, + { value: 'utility', label: 'Utility' }, + { value: 'risk_reward', label: 'Risk/Reward' }, + ], + }, + ], + changelog: [ + { + id: 'categoryFilter', + label: 'Category', + type: 'select', + options: [ + { value: 'all', label: 'All Categories' }, + { value: 'balance', label: 'Balance Changes' }, + { value: 'new_content', label: 'New Content' }, + { value: 'bug_fixes', label: 'Bug Fixes' }, + { value: 'removed', label: 'Removed' }, + { value: 'other', label: 'Other' }, + ], + }, + { + id: 'sortBy', + label: 'Sort By', + type: 'select', + options: [ + { value: 'date_desc', label: 'Newest First' }, + { value: 'date_asc', label: 'Oldest First' }, + ], + }, + ], +}; + +// ======================================== +// State +// ======================================== + +let isSheetOpen = false; +let previouslyFocusedElement: HTMLElement | null = null; + +// ======================================== +// Show/Hide Logic +// ======================================== + +/** + * Show the filter bottom sheet + */ +export function showFilterSheet(): void { + previouslyFocusedElement = document.activeElement as HTMLElement; + + const currentTab = getState('currentTab') || 'items'; + let sheet = safeGetElementById('filter-bottom-sheet'); + + if (!TAB_FILTERS[currentTab]) { + logger.debug({ + operation: 'mobile-filters.show', + data: { tab: currentTab, reason: 'no_filters_for_tab' }, + }); + return; + } + + if (sheet) { + const content = sheet.querySelector('#filter-sheet-content'); + if (content) { + const filters = TAB_FILTERS[currentTab] || []; + content.innerHTML = renderFilterGroups(filters); + } + } else { + const filters = TAB_FILTERS[currentTab] || []; + sheet = createFilterSheet(currentTab, filters); + document.body.appendChild(sheet); + setupSheetEventListeners(sheet, hideFilterSheet, clearSheetFilters, applyFiltersFromSheet, updateFilterBadge); + } + + syncFiltersToSheet(); + + isSheetOpen = true; + sheet.classList.add('active'); + document.body.classList.add('filter-sheet-open'); + + document.addEventListener('keydown', _handleKeyboardNavigation); + document.addEventListener('keydown', _handleFocusTrap); + + requestAnimationFrame(() => { + const firstInput = sheet?.querySelector('select, input'); + firstInput?.focus(); + }); + + logger.debug({ + operation: 'mobile-filters.sheet', + data: { action: 'open', tab: currentTab }, + }); +} + +/** + * Hide the filter bottom sheet + */ +export function hideFilterSheet(): void { + const sheet = safeGetElementById('filter-bottom-sheet'); + if (!sheet) return; + + isSheetOpen = false; + sheet.classList.remove('active'); + document.body.classList.remove('filter-sheet-open'); + + document.removeEventListener('keydown', _handleKeyboardNavigation); + document.removeEventListener('keydown', _handleFocusTrap); + + if (previouslyFocusedElement) { + previouslyFocusedElement.focus(); + previouslyFocusedElement = null; + } + + logger.debug({ + operation: 'mobile-filters.sheet', + data: { action: 'close' }, + }); +} + +/** + * Toggle the filter bottom sheet + */ +export function toggleFilterSheet(): void { + if (isSheetOpen) { + hideFilterSheet(); + } else { + showFilterSheet(); + } +} + +// Wrapper functions for keyboard handlers that pass current state +function _handleKeyboardNavigation(e: KeyboardEvent): void { + handleKeyboardNavigation(e, isSheetOpen, hideFilterSheet); +} + +function _handleFocusTrap(e: KeyboardEvent): void { + handleFocusTrap(e, isSheetOpen); +} + +/** + * Update filter badge (bound to TAB_FILTERS) + */ +export function updateFilterBadge(): void { + _updateFilterBadge(TAB_FILTERS); +} + +/** + * Create the mobile filter button + */ +function createMobileFilterButton(): HTMLElement { + const btn = document.createElement('button'); + btn.className = 'mobile-filter-btn'; + btn.type = 'button'; + btn.setAttribute('aria-label', 'Open filters'); + btn.setAttribute('aria-haspopup', 'dialog'); + btn.setAttribute('aria-expanded', 'false'); + + btn.innerHTML = ` + + Filters + + `; + + btn.addEventListener('click', () => { + toggleFilterSheet(); + btn.setAttribute('aria-expanded', isSheetOpen ? 'true' : 'false'); + }); + + return btn; +} + +/** + * Inject the mobile filter button into the controls + */ +function injectMobileFilterButton(): void { + const controls = safeQuerySelector('.controls .container'); + if (!controls) return; + + if (safeQuerySelector('.mobile-filter-btn')) return; + + const btn = createMobileFilterButton(); + + const searchBox = controls.querySelector('.search-box'); + if (searchBox) { + searchBox.after(btn); + } else { + controls.appendChild(btn); + } +} + +// ======================================== +// Initialization +// ======================================== + +/** + * Initialize mobile filters + */ +export function initMobileFilters(): void { + injectMobileFilterButton(); + + subscribe('currentTab', () => { + setTimeout(() => { + updateFilterBadge(); + }, 100); + }); + + const filtersContainer = safeGetElementById('filters'); + if (filtersContainer) { + filtersContainer.addEventListener('change', () => { + updateFilterBadge(); + }); + } + + updateFilterBadge(); + + logger.info({ + operation: 'mobile-filters.init', + data: { status: 'initialized' }, + }); +} + +// Export for external use diff --git a/src/modules/mobile/mobile-nav.ts b/src/modules/mobile/mobile-nav.ts new file mode 100644 index 00000000..a6888387 --- /dev/null +++ b/src/modules/mobile/mobile-nav.ts @@ -0,0 +1,459 @@ +// ======================================== +// Mobile Bottom Navigation Module +// ======================================== +// Provides a thumb-friendly bottom navigation for mobile users +// with an accessible slide-up "More" drawer +// ======================================== + +import { getState, setState, subscribe, type TabName } from '../store.ts'; +import { safeGetElementById, safeQuerySelector, safeQuerySelectorAll } from '../utils.ts'; +import { logger } from '../logger.ts'; + +// ======================================== +// Types +// ======================================== + +interface MoreMenuConfig { + tab: string; + label: string; + icon: string; +} + +// ======================================== +// Constants +// ======================================== + +const MORE_MENU_TABS: MoreMenuConfig[] = [ + { tab: 'advisor', label: 'Advisor', icon: '🤖' }, + { tab: 'characters', label: 'Characters', icon: '👤' }, + { tab: 'shrines', label: 'Shrines', icon: '⛩️' }, + { tab: 'calculator', label: 'Calculator', icon: '🧮' }, + { tab: 'changelog', label: 'Changelog', icon: '📋' }, + { tab: 'about', label: 'About', icon: 'ℹ️' }, +]; + +const MORE_TAB_NAMES = new Set(MORE_MENU_TABS.map(t => t.tab)); + +// ======================================== +// State +// ======================================== + +let isMenuOpen = false; +let previouslyFocusedElement: HTMLElement | null = null; +let focusableElements: HTMLElement[] = []; + +// ======================================== +// More Menu Component +// ======================================== + +/** + * Create the "More" menu drawer HTML + */ +function createMoreMenu(): HTMLElement { + const menu = document.createElement('div'); + menu.id = 'more-menu'; + menu.className = 'more-menu'; + menu.setAttribute('role', 'dialog'); + menu.setAttribute('aria-modal', 'true'); + menu.setAttribute('aria-label', 'Additional navigation tabs'); + + const currentTab = getState('currentTab'); + + // SAFE: tab values are from hardcoded MORE_MENU_TABS constant + menu.innerHTML = ` + +
+ +
+ More Options + +
+ +
+ `; + + return menu; +} + +/** + * Update menu items to reflect current tab + */ +function updateMenuItems(currentTab: string): void { + const menu = safeGetElementById('more-menu'); + if (!menu) return; + + const items = menu.querySelectorAll('.more-menu-item'); + items.forEach(item => { + const btn = item as HTMLElement; + const tab = btn.dataset.tab; + const isCurrent = tab === currentTab; + + btn.classList.toggle('current', isCurrent); + btn.setAttribute('aria-current', isCurrent ? 'page' : 'false'); + }); +} + +/** + * Get all focusable elements within the menu + */ +function getFocusableElements(): HTMLElement[] { + const menu = safeGetElementById('more-menu'); + if (!menu) return []; + + return Array.from( + menu.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])') + ).filter(el => el.offsetParent !== null); // Filter out hidden elements +} + +/** + * Trap focus within the menu + */ +function handleFocusTrap(e: KeyboardEvent): void { + if (e.key !== 'Tab' || !isMenuOpen) return; + + focusableElements = getFocusableElements(); + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + // Shift + Tab: moving backwards + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement?.focus(); + } + } else if (document.activeElement === lastElement) { + // Tab: moving forwards + e.preventDefault(); + firstElement?.focus(); + } +} + +/** + * Handle keyboard navigation within menu + */ +function handleKeyboardNavigation(e: KeyboardEvent): void { + if (!isMenuOpen) return; + + const menu = safeGetElementById('more-menu'); + if (!menu) return; + + switch (e.key) { + case 'Escape': + e.preventDefault(); + hideMoreMenu(); + break; + + case 'ArrowDown': + case 'ArrowRight': { + e.preventDefault(); + const items = Array.from(menu.querySelectorAll('.more-menu-item')); + const currentIndex = items.indexOf(document.activeElement as HTMLElement); + const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; + items[nextIndex]?.focus(); + break; + } + + case 'ArrowUp': + case 'ArrowLeft': { + e.preventDefault(); + const items = Array.from(menu.querySelectorAll('.more-menu-item')); + const currentIndex = items.indexOf(document.activeElement as HTMLElement); + const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; + items[prevIndex]?.focus(); + break; + } + + case 'Home': { + e.preventDefault(); + const items = menu.querySelectorAll('.more-menu-item'); + (items[0] as HTMLElement)?.focus(); + break; + } + + case 'End': { + e.preventDefault(); + const items = menu.querySelectorAll('.more-menu-item'); + (items[items.length - 1] as HTMLElement)?.focus(); + break; + } + } +} + +/** + * Show the more menu with animation + */ +function showMoreMenu(): void { + // Save the currently focused element to restore later + previouslyFocusedElement = document.activeElement as HTMLElement; + + let menu = safeGetElementById('more-menu'); + + if (menu) { + // Update items in case current tab changed + const currentTab = getState('currentTab'); + if (currentTab) { + updateMenuItems(currentTab); + } + } else { + menu = createMoreMenu(); + document.body.appendChild(menu); + setupMenuEventListeners(menu); + } + + isMenuOpen = true; + menu.classList.add('active'); + document.body.classList.add('more-menu-open'); + + // Add keyboard event listeners + document.addEventListener('keydown', handleKeyboardNavigation); + document.addEventListener('keydown', handleFocusTrap); + + // Focus the first menu item after animation + requestAnimationFrame(() => { + const firstItem = menu?.querySelector('.more-menu-item'); + firstItem?.focus(); + }); + + logger.debug({ + operation: 'mobile-nav.more-menu', + data: { action: 'open' }, + }); +} + +/** + * Hide the more menu + */ +function hideMoreMenu(): void { + const menu = safeGetElementById('more-menu'); + if (!menu) return; + + isMenuOpen = false; + menu.classList.remove('active'); + document.body.classList.remove('more-menu-open'); + + // Remove keyboard event listeners + document.removeEventListener('keydown', handleKeyboardNavigation); + document.removeEventListener('keydown', handleFocusTrap); + + // Restore focus to the previously focused element (the More button) + if (previouslyFocusedElement) { + previouslyFocusedElement.focus(); + previouslyFocusedElement = null; + } + + logger.debug({ + operation: 'mobile-nav.more-menu', + data: { action: 'close' }, + }); +} + +/** + * Toggle the more menu + */ +function toggleMoreMenu(): void { + if (isMenuOpen) { + hideMoreMenu(); + } else { + showMoreMenu(); + } +} + +/** + * Setup event listeners for the menu + */ +function setupMenuEventListeners(menu: HTMLElement): void { + // Backdrop click to close + const backdrop = menu.querySelector('.more-menu-backdrop'); + backdrop?.addEventListener('click', hideMoreMenu); + + // Close button click + const closeBtn = menu.querySelector('.more-menu-close'); + closeBtn?.addEventListener('click', hideMoreMenu); + + // Menu item clicks - use event delegation + const itemsContainer = menu.querySelector('.more-menu-items'); + itemsContainer?.addEventListener('click', (e: Event) => { + const target = e.target as HTMLElement; + const item = target.closest('.more-menu-item'); + + if (item) { + const tab = item.dataset.tab; + if (tab) { + switchTab(tab); + hideMoreMenu(); + } + } + }); + + // Handle Enter/Space on menu items for keyboard users + itemsContainer?.addEventListener('keydown', (e: Event) => { + const keyEvent = e as KeyboardEvent; + if (keyEvent.key === 'Enter' || keyEvent.key === ' ') { + const target = keyEvent.target as HTMLElement; + if (target.classList.contains('more-menu-item')) { + keyEvent.preventDefault(); + const tab = target.dataset.tab; + if (tab) { + switchTab(tab); + hideMoreMenu(); + } + } + } + }); +} + +// ======================================== +// Tab Switching +// ======================================== + +/** + * Switch to a tab (triggers existing tab system) + */ +function switchTab(tabName: string): void { + // Find and click the corresponding tab button in the main nav + const tabBtn = safeQuerySelector(`.tab-btn[data-tab="${tabName}"]`) as HTMLElement | null; + + if (tabBtn) { + tabBtn.click(); + } else { + // Fallback: directly set state and trigger render + setState('currentTab', tabName as TabName); + } +} + +/** + * Update mobile nav active state based on current tab + */ +function updateMobileNavState(currentTab: string): void { + const navItems = safeQuerySelectorAll('.mobile-bottom-nav .nav-item'); + + // Check if current tab is in the "more" menu + const isMoreTab = MORE_TAB_NAMES.has(currentTab); + const currentMoreTab = isMoreTab ? MORE_MENU_TABS.find(t => t.tab === currentTab) : null; + + navItems.forEach(item => { + const btn = item as HTMLElement; + const tab = btn.dataset.tab; + + if (tab === 'more') { + // Update the More button to show current tab if viewing a More tab + const iconSpan = btn.querySelector('.nav-icon'); + const labelSpan = btn.querySelector('span:not(.nav-icon)'); + + if (isMoreTab && currentMoreTab) { + // Show current tab's icon and label + if (iconSpan) iconSpan.textContent = currentMoreTab.icon; + if (labelSpan) labelSpan.textContent = currentMoreTab.label; + btn.classList.add('active'); + btn.setAttribute('aria-label', `${currentMoreTab.label} (tap for more options)`); + } else { + // Reset to default More button + if (iconSpan) iconSpan.textContent = '≡'; + if (labelSpan) labelSpan.textContent = 'More'; + btn.classList.remove('active'); + btn.setAttribute('aria-label', 'More tabs'); + } + btn.setAttribute('aria-expanded', 'false'); + } else if (tab === currentTab) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Also update menu items if menu exists + updateMenuItems(currentTab); +} + +// ======================================== +// Event Handlers +// ======================================== + +/** + * Handle mobile nav item clicks + */ +function handleNavClick(e: Event): void { + const target = e.target as HTMLElement; + const navItem = target.closest('.nav-item'); + + if (!navItem) return; + + const tab = navItem.dataset.tab; + + if (tab === 'more') { + toggleMoreMenu(); + } else if (tab) { + // Close menu if open when switching to a main nav tab + if (isMenuOpen) { + hideMoreMenu(); + } + switchTab(tab); + } +} + +// ======================================== +// Initialization +// ======================================== + +/** + * Initialize mobile bottom navigation + */ +export function initMobileNav(): void { + const mobileNav = safeQuerySelector('.mobile-bottom-nav'); + + if (!mobileNav) { + logger.warn({ + operation: 'mobile-nav.init', + data: { reason: 'mobile_nav_not_found' }, + }); + return; + } + + // Add click listener using event delegation + mobileNav.addEventListener('click', handleNavClick); + + // Setup aria-expanded on the More button + const moreBtn = mobileNav.querySelector('[data-tab="more"]'); + if (moreBtn) { + moreBtn.setAttribute('aria-expanded', 'false'); + moreBtn.setAttribute('aria-haspopup', 'dialog'); + } + + // Subscribe to tab changes to update nav state + subscribe('currentTab', newTab => { + updateMobileNavState(newTab as string); + }); + + // Initialize with current tab + const currentTab = getState('currentTab'); + if (currentTab) { + updateMobileNavState(currentTab as string); + } + + logger.info({ + operation: 'mobile-nav.init', + data: { status: 'initialized' }, + }); +} + +// Export for external use +export { hideMoreMenu, showMoreMenu, toggleMoreMenu }; diff --git a/src/modules/mobile/pull-refresh.ts b/src/modules/mobile/pull-refresh.ts new file mode 100644 index 00000000..330162f4 --- /dev/null +++ b/src/modules/mobile/pull-refresh.ts @@ -0,0 +1,332 @@ +// ======================================== +// MegaBonk Pull-to-Refresh Module +// ======================================== +// Enables pull-down gesture to refresh data on touch devices + +import { loadAllData } from '../data-service.ts'; +import { logger } from '../logger.ts'; +import { ToastManager } from '../toast.ts'; + +// ======================================== +// Constants +// ======================================== + +/** Pull-to-refresh configuration (exported for testing) */ +export const PULL_REFRESH_CONFIG = { + PULL_THRESHOLD: 70, // Pixels to pull before refresh triggers (matches Twitter/Gmail) + SPINNER_SHOW_THRESHOLD: 40, // Pixels before spinner becomes visible + MAX_PULL_DISTANCE: 140, // Maximum visual pull distance + RESISTANCE_FACTOR: 0.4, // Base resistance factor + MAX_VELOCITY: 2, // px/ms — reject fast flicks above this velocity + COOLDOWN_MS: 2000, // Cooldown period after a refresh before allowing another +}; + +// ======================================== +// State +// ======================================== + +interface PullRefreshState { + startY: number; + currentY: number; + startTime: number; + lastMoveY: number; + lastMoveTime: number; + velocity: number; + isPulling: boolean; + isRefreshing: boolean; + lastRefreshTime: number; + indicator: HTMLElement | null; +} + +const state: PullRefreshState = { + startY: 0, + currentY: 0, + startTime: 0, + lastMoveY: 0, + lastMoveTime: 0, + velocity: 0, + isPulling: false, + isRefreshing: false, + lastRefreshTime: 0, + indicator: null, +}; + +// ======================================== +// Detection +// ======================================== + +/** + * Check if we're on a touch device + */ +function isTouchDevice(): boolean { + return 'ontouchstart' in globalThis || navigator.maxTouchPoints > 0; +} + +/** + * Check if page is scrolled to the top + */ +function isAtTop(): boolean { + return window.scrollY === 0; +} + +/** + * Check if cooldown period has elapsed since last refresh + */ +function isCooldownElapsed(): boolean { + return Date.now() - state.lastRefreshTime >= PULL_REFRESH_CONFIG.COOLDOWN_MS; +} + +// ======================================== +// UI +// ======================================== + +/** + * Create the pull-to-refresh indicator element + */ +function createIndicator(): HTMLElement { + const indicator = document.createElement('div'); + indicator.className = 'pull-refresh-indicator'; + indicator.innerHTML = ` +
+
+ Pull to refresh +
+ `; + document.body.prepend(indicator); + return indicator; +} + +/** + * Update the indicator position and state + */ +function updateIndicator(distance: number, isRefreshing: boolean = false): void { + if (!state.indicator) return; + + const progress = Math.min(distance / PULL_REFRESH_CONFIG.PULL_THRESHOLD, 1); + const clampedDistance = Math.min(distance, PULL_REFRESH_CONFIG.MAX_PULL_DISTANCE); + + state.indicator.style.setProperty('--pull-distance', `${clampedDistance}px`); + state.indicator.style.setProperty('--pull-progress', `${progress}`); + + const textEl = state.indicator.querySelector('.pull-refresh-text'); + if (textEl) { + if (isRefreshing) { + textEl.textContent = 'Refreshing...'; + } else if (distance >= PULL_REFRESH_CONFIG.PULL_THRESHOLD) { + textEl.textContent = 'Release to refresh'; + } else { + textEl.textContent = 'Pull to refresh'; + } + } + + state.indicator.classList.toggle('active', distance >= PULL_REFRESH_CONFIG.SPINNER_SHOW_THRESHOLD); + state.indicator.classList.toggle('threshold-reached', distance >= PULL_REFRESH_CONFIG.PULL_THRESHOLD); + state.indicator.classList.toggle('refreshing', isRefreshing); +} + +/** + * Reset the indicator to hidden state + */ +function resetIndicator(): void { + if (!state.indicator) return; + + state.indicator.classList.add('resetting'); + updateIndicator(0, false); + + setTimeout(() => { + state.indicator?.classList.remove('active', 'threshold-reached', 'refreshing', 'resetting'); + }, 300); +} + +// ======================================== +// Touch Handlers +// ======================================== + +function handleTouchStart(e: TouchEvent): void { + // Only enable when at top of page, not refreshing, and cooldown elapsed + if (!isAtTop() || state.isRefreshing || !isCooldownElapsed()) return; + + const touch = e.touches[0]; + if (!touch) return; + + state.startY = touch.clientY; + state.startTime = Date.now(); + state.lastMoveY = touch.clientY; + state.lastMoveTime = Date.now(); + state.velocity = 0; + state.isPulling = true; +} + +function handleTouchMove(e: TouchEvent): void { + if (!state.isPulling || state.isRefreshing) return; + if (!isAtTop()) { + state.isPulling = false; + return; + } + + const touch = e.touches[0]; + if (!touch) return; + + state.currentY = touch.clientY; + const now = Date.now(); + let distance = state.currentY - state.startY; + + // Only allow pulling down + if (distance < 0) { + state.isPulling = false; + return; + } + + // Track velocity for flick rejection + const dt = now - state.lastMoveTime; + if (dt > 0) { + const dy = Math.abs(touch.clientY - state.lastMoveY); + state.velocity = dy / dt; // px/ms + } + state.lastMoveY = touch.clientY; + state.lastMoveTime = now; + + // Rubber band feel: exponential dampening increases with distance + if (distance > PULL_REFRESH_CONFIG.PULL_THRESHOLD) { + const overPull = distance - PULL_REFRESH_CONFIG.PULL_THRESHOLD; + const dampening = PULL_REFRESH_CONFIG.RESISTANCE_FACTOR * Math.exp(-overPull / 200); + distance = PULL_REFRESH_CONFIG.PULL_THRESHOLD + overPull * dampening; + } + + // Prevent default scrolling when pulling + if (distance > 10) { + e.preventDefault(); + } + + updateIndicator(distance); +} + +async function handleTouchEnd(): Promise { + if (!state.isPulling) return; + state.isPulling = false; + + const distance = state.currentY - state.startY; + + // Reject fast flicks — only deliberate pulls trigger refresh + const isFastFlick = state.velocity > PULL_REFRESH_CONFIG.MAX_VELOCITY; + + if (distance >= PULL_REFRESH_CONFIG.PULL_THRESHOLD && !state.isRefreshing && !isFastFlick) { + await triggerRefresh(); + } else { + resetIndicator(); + } + + state.startY = 0; + state.currentY = 0; + state.startTime = 0; + state.lastMoveY = 0; + state.lastMoveTime = 0; + state.velocity = 0; +} + +// ======================================== +// Refresh Logic +// ======================================== + +/** + * Trigger a data refresh + */ +async function triggerRefresh(): Promise { + state.isRefreshing = true; + updateIndicator(PULL_REFRESH_CONFIG.PULL_THRESHOLD, true); + + logger.info({ + operation: 'pull-refresh.triggered', + data: { source: 'touch-gesture' }, + }); + + const startTime = performance.now(); + + try { + await loadAllData(); + + const duration = Math.round(performance.now() - startTime); + logger.info({ + operation: 'pull-refresh.complete', + durationMs: duration, + success: true, + }); + + ToastManager.success('Data refreshed!'); + } catch (error) { + const err = error as Error; + logger.error({ + operation: 'pull-refresh.failed', + error: { + name: err.name, + message: err.message, + module: 'pull-refresh', + }, + }); + ToastManager.error('Failed to refresh data'); + } finally { + state.isRefreshing = false; + state.lastRefreshTime = Date.now(); + resetIndicator(); + } +} + +// ======================================== +// Initialization +// ======================================== + +/** + * Initialize pull-to-refresh functionality + * Only activates on touch devices + */ +export function initPullRefresh(): void { + // Only enable on touch devices + if (!isTouchDevice()) { + logger.debug({ + operation: 'pull-refresh.skip', + data: { reason: 'not_touch_device' }, + }); + return; + } + + // Create the indicator element + state.indicator = createIndicator(); + + // Add touch event listeners with passive: false for preventDefault + document.addEventListener('touchstart', handleTouchStart, { passive: true }); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd, { passive: true }); + + logger.info({ + operation: 'pull-refresh.init', + data: { enabled: true }, + }); +} + +/** + * Clean up pull-to-refresh (for testing) + */ +export function cleanupPullRefresh(): void { + document.removeEventListener('touchstart', handleTouchStart); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + + if (state.indicator) { + state.indicator.remove(); + state.indicator = null; + } + + state.isPulling = false; + state.isRefreshing = false; +} + +// ======================================== +// Global Exports +// ======================================== + +if (typeof globalThis !== 'undefined') { + Object.assign(globalThis, { + initPullRefresh, + cleanupPullRefresh, + }); +} diff --git a/src/modules/pull-refresh.ts b/src/modules/pull-refresh.ts index 09d8ca5b..0626ca48 100644 --- a/src/modules/pull-refresh.ts +++ b/src/modules/pull-refresh.ts @@ -1,332 +1,2 @@ -// ======================================== -// MegaBonk Pull-to-Refresh Module -// ======================================== -// Enables pull-down gesture to refresh data on touch devices - -import { loadAllData } from './data-service.ts'; -import { logger } from './logger.ts'; -import { ToastManager } from './toast.ts'; - -// ======================================== -// Constants -// ======================================== - -/** Pull-to-refresh configuration (exported for testing) */ -export const PULL_REFRESH_CONFIG = { - PULL_THRESHOLD: 70, // Pixels to pull before refresh triggers (matches Twitter/Gmail) - SPINNER_SHOW_THRESHOLD: 40, // Pixels before spinner becomes visible - MAX_PULL_DISTANCE: 140, // Maximum visual pull distance - RESISTANCE_FACTOR: 0.4, // Base resistance factor - MAX_VELOCITY: 2, // px/ms — reject fast flicks above this velocity - COOLDOWN_MS: 2000, // Cooldown period after a refresh before allowing another -}; - -// ======================================== -// State -// ======================================== - -interface PullRefreshState { - startY: number; - currentY: number; - startTime: number; - lastMoveY: number; - lastMoveTime: number; - velocity: number; - isPulling: boolean; - isRefreshing: boolean; - lastRefreshTime: number; - indicator: HTMLElement | null; -} - -const state: PullRefreshState = { - startY: 0, - currentY: 0, - startTime: 0, - lastMoveY: 0, - lastMoveTime: 0, - velocity: 0, - isPulling: false, - isRefreshing: false, - lastRefreshTime: 0, - indicator: null, -}; - -// ======================================== -// Detection -// ======================================== - -/** - * Check if we're on a touch device - */ -function isTouchDevice(): boolean { - return 'ontouchstart' in globalThis || navigator.maxTouchPoints > 0; -} - -/** - * Check if page is scrolled to the top - */ -function isAtTop(): boolean { - return window.scrollY === 0; -} - -/** - * Check if cooldown period has elapsed since last refresh - */ -function isCooldownElapsed(): boolean { - return Date.now() - state.lastRefreshTime >= PULL_REFRESH_CONFIG.COOLDOWN_MS; -} - -// ======================================== -// UI -// ======================================== - -/** - * Create the pull-to-refresh indicator element - */ -function createIndicator(): HTMLElement { - const indicator = document.createElement('div'); - indicator.className = 'pull-refresh-indicator'; - indicator.innerHTML = ` -
-
- Pull to refresh -
- `; - document.body.prepend(indicator); - return indicator; -} - -/** - * Update the indicator position and state - */ -function updateIndicator(distance: number, isRefreshing: boolean = false): void { - if (!state.indicator) return; - - const progress = Math.min(distance / PULL_REFRESH_CONFIG.PULL_THRESHOLD, 1); - const clampedDistance = Math.min(distance, PULL_REFRESH_CONFIG.MAX_PULL_DISTANCE); - - state.indicator.style.setProperty('--pull-distance', `${clampedDistance}px`); - state.indicator.style.setProperty('--pull-progress', `${progress}`); - - const textEl = state.indicator.querySelector('.pull-refresh-text'); - if (textEl) { - if (isRefreshing) { - textEl.textContent = 'Refreshing...'; - } else if (distance >= PULL_REFRESH_CONFIG.PULL_THRESHOLD) { - textEl.textContent = 'Release to refresh'; - } else { - textEl.textContent = 'Pull to refresh'; - } - } - - state.indicator.classList.toggle('active', distance >= PULL_REFRESH_CONFIG.SPINNER_SHOW_THRESHOLD); - state.indicator.classList.toggle('threshold-reached', distance >= PULL_REFRESH_CONFIG.PULL_THRESHOLD); - state.indicator.classList.toggle('refreshing', isRefreshing); -} - -/** - * Reset the indicator to hidden state - */ -function resetIndicator(): void { - if (!state.indicator) return; - - state.indicator.classList.add('resetting'); - updateIndicator(0, false); - - setTimeout(() => { - state.indicator?.classList.remove('active', 'threshold-reached', 'refreshing', 'resetting'); - }, 300); -} - -// ======================================== -// Touch Handlers -// ======================================== - -function handleTouchStart(e: TouchEvent): void { - // Only enable when at top of page, not refreshing, and cooldown elapsed - if (!isAtTop() || state.isRefreshing || !isCooldownElapsed()) return; - - const touch = e.touches[0]; - if (!touch) return; - - state.startY = touch.clientY; - state.startTime = Date.now(); - state.lastMoveY = touch.clientY; - state.lastMoveTime = Date.now(); - state.velocity = 0; - state.isPulling = true; -} - -function handleTouchMove(e: TouchEvent): void { - if (!state.isPulling || state.isRefreshing) return; - if (!isAtTop()) { - state.isPulling = false; - return; - } - - const touch = e.touches[0]; - if (!touch) return; - - state.currentY = touch.clientY; - const now = Date.now(); - let distance = state.currentY - state.startY; - - // Only allow pulling down - if (distance < 0) { - state.isPulling = false; - return; - } - - // Track velocity for flick rejection - const dt = now - state.lastMoveTime; - if (dt > 0) { - const dy = Math.abs(touch.clientY - state.lastMoveY); - state.velocity = dy / dt; // px/ms - } - state.lastMoveY = touch.clientY; - state.lastMoveTime = now; - - // Rubber band feel: exponential dampening increases with distance - if (distance > PULL_REFRESH_CONFIG.PULL_THRESHOLD) { - const overPull = distance - PULL_REFRESH_CONFIG.PULL_THRESHOLD; - const dampening = PULL_REFRESH_CONFIG.RESISTANCE_FACTOR * Math.exp(-overPull / 200); - distance = PULL_REFRESH_CONFIG.PULL_THRESHOLD + overPull * dampening; - } - - // Prevent default scrolling when pulling - if (distance > 10) { - e.preventDefault(); - } - - updateIndicator(distance); -} - -async function handleTouchEnd(): Promise { - if (!state.isPulling) return; - state.isPulling = false; - - const distance = state.currentY - state.startY; - - // Reject fast flicks — only deliberate pulls trigger refresh - const isFastFlick = state.velocity > PULL_REFRESH_CONFIG.MAX_VELOCITY; - - if (distance >= PULL_REFRESH_CONFIG.PULL_THRESHOLD && !state.isRefreshing && !isFastFlick) { - await triggerRefresh(); - } else { - resetIndicator(); - } - - state.startY = 0; - state.currentY = 0; - state.startTime = 0; - state.lastMoveY = 0; - state.lastMoveTime = 0; - state.velocity = 0; -} - -// ======================================== -// Refresh Logic -// ======================================== - -/** - * Trigger a data refresh - */ -async function triggerRefresh(): Promise { - state.isRefreshing = true; - updateIndicator(PULL_REFRESH_CONFIG.PULL_THRESHOLD, true); - - logger.info({ - operation: 'pull-refresh.triggered', - data: { source: 'touch-gesture' }, - }); - - const startTime = performance.now(); - - try { - await loadAllData(); - - const duration = Math.round(performance.now() - startTime); - logger.info({ - operation: 'pull-refresh.complete', - durationMs: duration, - success: true, - }); - - ToastManager.success('Data refreshed!'); - } catch (error) { - const err = error as Error; - logger.error({ - operation: 'pull-refresh.failed', - error: { - name: err.name, - message: err.message, - module: 'pull-refresh', - }, - }); - ToastManager.error('Failed to refresh data'); - } finally { - state.isRefreshing = false; - state.lastRefreshTime = Date.now(); - resetIndicator(); - } -} - -// ======================================== -// Initialization -// ======================================== - -/** - * Initialize pull-to-refresh functionality - * Only activates on touch devices - */ -export function initPullRefresh(): void { - // Only enable on touch devices - if (!isTouchDevice()) { - logger.debug({ - operation: 'pull-refresh.skip', - data: { reason: 'not_touch_device' }, - }); - return; - } - - // Create the indicator element - state.indicator = createIndicator(); - - // Add touch event listeners with passive: false for preventDefault - document.addEventListener('touchstart', handleTouchStart, { passive: true }); - document.addEventListener('touchmove', handleTouchMove, { passive: false }); - document.addEventListener('touchend', handleTouchEnd, { passive: true }); - - logger.info({ - operation: 'pull-refresh.init', - data: { enabled: true }, - }); -} - -/** - * Clean up pull-to-refresh (for testing) - */ -export function cleanupPullRefresh(): void { - document.removeEventListener('touchstart', handleTouchStart); - document.removeEventListener('touchmove', handleTouchMove); - document.removeEventListener('touchend', handleTouchEnd); - - if (state.indicator) { - state.indicator.remove(); - state.indicator = null; - } - - state.isPulling = false; - state.isRefreshing = false; -} - -// ======================================== -// Global Exports -// ======================================== - -if (typeof globalThis !== 'undefined') { - Object.assign(globalThis, { - initPullRefresh, - cleanupPullRefresh, - }); -} +// SHIM — module moved to mobile/pull-refresh.ts (remove after all imports updated) +export * from './mobile/pull-refresh.ts'; diff --git a/src/script.ts b/src/script.ts index fc83ae06..6c04cdcd 100644 --- a/src/script.ts +++ b/src/script.ts @@ -23,10 +23,8 @@ import { setupImageFallbackHandler, setupBlurUpHandler } from './modules/utils.t import { logger } from './modules/logger.ts'; import { setupOfflineListeners } from './modules/offline-ui.ts'; import { scheduleModulePreload } from './modules/events.ts'; -import { initMobileNav } from './modules/mobile-nav.ts'; -import { initMobileFilters } from './modules/mobile-filters.ts'; +import { initMobileNav, initMobileFilters, initPullRefresh } from './modules/mobile/index.ts'; import { initRecentlyViewed } from './modules/recently-viewed.ts'; -import { initPullRefresh } from './modules/pull-refresh.ts'; import { initWhatsNew, initFooterVersion } from './modules/whats-new.ts'; // Note: Tab-specific modules (advisor, build-planner, calculator, changelog, about) are now lazy-loaded // via the tab-loader module when their tabs are first accessed