This commit includes all the core files from the jQuery-free migration: ### TypeScript Files Added: - tsconfig.json - TypeScript configuration - src/types/index.ts - Comprehensive type definitions - src/assets/scripts/app.ts - Main application TypeScript version - src/assets/scripts/components/Chart.ts - Chart component TypeScript - src/assets/scripts/components/Sidebar.ts - Sidebar component TypeScript - src/assets/scripts/datatable/index.ts - DataTable TypeScript implementation - src/assets/scripts/datepicker/index.ts - DatePicker TypeScript implementation - src/assets/scripts/ui/index.ts - UI components TypeScript - src/assets/scripts/utils/date.ts - Date utilities TypeScript - src/assets/scripts/utils/dom.ts - DOM utilities TypeScript - src/assets/scripts/utils/theme.ts - Theme utilities TypeScript - src/assets/scripts/vectorMaps/index.ts - Vector maps TypeScript - webpack/rules/ts.js - TypeScript webpack rules ### Updated JavaScript Files: - src/assets/scripts/app.js - Updated main application - src/assets/scripts/datatable/index.js - Updated DataTable implementation - src/assets/scripts/datepicker/index.js - Updated DatePicker implementation - src/assets/scripts/ui/index.js - Updated UI components - src/assets/scripts/utils/theme.js - Updated theme utilities ### Configuration Files: - .npmignore - NPM package ignore rules - package-lock.json - Updated dependencies lock file - webpack/config.js - Updated webpack configuration - webpack/rules/index.js - Updated webpack rules - webpack/rules/js.js - Updated JavaScript rules These files complete the jQuery-free migration with modern TypeScript implementations and maintain full backward compatibility. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>pull/317/head
| @ -0,0 +1,44 @@ | |||
| # Development files | |||
| node_modules/ | |||
| .git/ | |||
| .gitignore | |||
| .eslintrc.js | |||
| .stylelintrc.json | |||
| # Build artifacts that aren't needed | |||
| webpack.config.js | |||
| babel.config.js | |||
| # Documentation that's not essential for npm users | |||
| .github/ | |||
| docs/ | |||
| # IDE files | |||
| .vscode/ | |||
| .idea/ | |||
| *.swp | |||
| *.swo | |||
| *~ | |||
| # OS files | |||
| .DS_Store | |||
| Thumbs.db | |||
| # Logs | |||
| *.log | |||
| npm-debug.log* | |||
| yarn-debug.log* | |||
| yarn-error.log* | |||
| # Runtime data | |||
| pids | |||
| *.pid | |||
| *.seed | |||
| *.pid.lock | |||
| # Coverage directory used by tools like istanbul | |||
| coverage/ | |||
| # Temporary folders | |||
| tmp/ | |||
| temp/ | |||
| @ -0,0 +1,757 @@ | |||
| /** | |||
| * Modern Adminator Application with TypeScript | |||
| * Main application entry point with enhanced mobile support and type safety | |||
| */ | |||
| import { DOM } from './utils/dom'; | |||
| import { ThemeManager } from './utils/theme'; | |||
| import { Sidebar } from './components/Sidebar'; | |||
| import { ChartComponent } from './components/Chart'; | |||
| import UIComponents from './ui'; | |||
| import DataTable from './datatable'; | |||
| import DatePicker from './datepicker'; | |||
| import VectorMaps from './vectorMaps'; | |||
| import type { ComponentInterface } from '../../types'; | |||
| // Import styles | |||
| import '../styles/index.scss'; | |||
| // Import other modules that don't need immediate modernization | |||
| import './fullcalendar'; | |||
| import './masonry'; | |||
| import './popover'; | |||
| import './scrollbar'; | |||
| import './search'; | |||
| import './skycons'; | |||
| import './chat'; | |||
| import './email'; | |||
| import './googleMaps'; | |||
| // Type definitions for the application | |||
| export interface AdminatorAppOptions { | |||
| autoInit?: boolean; | |||
| theme?: 'light' | 'dark' | 'auto'; | |||
| mobile?: { | |||
| enhanced?: boolean; | |||
| fullWidthSearch?: boolean; | |||
| disableDropdowns?: boolean; | |||
| }; | |||
| debug?: boolean; | |||
| } | |||
| export interface AdminatorAppState { | |||
| isInitialized: boolean; | |||
| isMobile: boolean; | |||
| currentTheme: 'light' | 'dark' | 'auto'; | |||
| components: Map<string, ComponentInterface>; | |||
| } | |||
| export interface AdminatorAppEvents { | |||
| ready: CustomEvent<{ app: AdminatorApp }>; | |||
| themeChanged: CustomEvent<{ theme: string; previousTheme: string }>; | |||
| mobileStateChanged: CustomEvent<{ isMobile: boolean }>; | |||
| componentAdded: CustomEvent<{ name: string; component: ComponentInterface }>; | |||
| componentRemoved: CustomEvent<{ name: string }>; | |||
| } | |||
| declare global { | |||
| interface Window { | |||
| AdminatorApp?: AdminatorApp; | |||
| } | |||
| } | |||
| export class AdminatorApp { | |||
| public options: AdminatorAppOptions; | |||
| public state: AdminatorAppState; | |||
| private resizeTimeout: number | null = null; | |||
| private eventHandlers: Map<string, EventListener> = new Map(); | |||
| private themeManager: typeof ThemeManager; | |||
| constructor(options: AdminatorAppOptions = {}) { | |||
| this.options = { | |||
| autoInit: true, | |||
| theme: 'auto', | |||
| mobile: { | |||
| enhanced: true, | |||
| fullWidthSearch: true, | |||
| disableDropdowns: false, | |||
| }, | |||
| debug: false, | |||
| ...options, | |||
| }; | |||
| this.themeManager = ThemeManager; | |||
| this.state = { | |||
| isInitialized: false, | |||
| isMobile: this.checkMobileState(), | |||
| currentTheme: 'light', | |||
| components: new Map(), | |||
| }; | |||
| if (this.options.autoInit) { | |||
| // Initialize when DOM is ready | |||
| DOM.ready(() => { | |||
| this.init(); | |||
| }); | |||
| } | |||
| } | |||
| /** | |||
| * Initialize the application | |||
| */ | |||
| public init(): void { | |||
| if (this.state.isInitialized) return; | |||
| this.log('Initializing Adminator App...'); | |||
| try { | |||
| // Initialize core components | |||
| this.initSidebar(); | |||
| this.initCharts(); | |||
| this.initDataTables(); | |||
| this.initDatePickers(); | |||
| this.initUIComponents(); | |||
| this.initVectorMaps(); | |||
| this.initTheme(); | |||
| this.initMobileEnhancements(); | |||
| // Setup global event listeners | |||
| this.setupGlobalEvents(); | |||
| this.state.isInitialized = true; | |||
| this.log('Adminator App initialized successfully'); | |||
| // Dispatch custom event for other scripts | |||
| this.dispatchEvent('ready', { app: this }); | |||
| } catch (error) { | |||
| console.error('Error initializing Adminator App:', error); | |||
| } | |||
| } | |||
| /** | |||
| * Initialize Sidebar component | |||
| */ | |||
| private initSidebar(): void { | |||
| if (DOM.exists('.sidebar')) { | |||
| const sidebar = new Sidebar(); | |||
| this.addComponent('sidebar', sidebar); | |||
| this.log('Sidebar component initialized'); | |||
| } | |||
| } | |||
| /** | |||
| * Initialize Chart components | |||
| */ | |||
| private initCharts(): void { | |||
| // Check if we have any chart elements | |||
| const hasCharts = DOM.exists('#sparklinedash') || | |||
| DOM.exists('.sparkline') || | |||
| DOM.exists('.sparkbar') || | |||
| DOM.exists('.sparktri') || | |||
| DOM.exists('.sparkdisc') || | |||
| DOM.exists('.sparkbull') || | |||
| DOM.exists('.sparkbox') || | |||
| DOM.exists('.easy-pie-chart') || | |||
| DOM.exists('#line-chart') || | |||
| DOM.exists('#area-chart') || | |||
| DOM.exists('#scatter-chart') || | |||
| DOM.exists('#bar-chart'); | |||
| if (hasCharts) { | |||
| const charts = new ChartComponent(); | |||
| this.addComponent('charts', charts); | |||
| this.log('Chart components initialized'); | |||
| } | |||
| } | |||
| /** | |||
| * Initialize DataTables | |||
| */ | |||
| private initDataTables(): void { | |||
| const dataTableElement = DOM.select('#dataTable'); | |||
| if (dataTableElement) { | |||
| DataTable.init(); | |||
| this.log('DataTable initialized'); | |||
| } | |||
| } | |||
| /** | |||
| * Initialize Date Pickers | |||
| */ | |||
| private initDatePickers(): void { | |||
| const startDatePickers = DOM.selectAll('.start-date'); | |||
| const endDatePickers = DOM.selectAll('.end-date'); | |||
| if (startDatePickers.length > 0 || endDatePickers.length > 0) { | |||
| DatePicker.init(); | |||
| this.log('Date pickers initialized'); | |||
| } | |||
| } | |||
| /** | |||
| * Initialize UI Components | |||
| */ | |||
| private initUIComponents(): void { | |||
| UIComponents.init(); | |||
| this.log('UI components initialized'); | |||
| } | |||
| /** | |||
| * Initialize Vector Maps | |||
| */ | |||
| private initVectorMaps(): void { | |||
| if (DOM.exists('#world-map-marker')) { | |||
| VectorMaps.init(); | |||
| this.log('Vector maps initialized'); | |||
| } | |||
| } | |||
| /** | |||
| * Initialize theme system with toggle | |||
| */ | |||
| private initTheme(): void { | |||
| this.log('Initializing theme system...'); | |||
| // Initialize theme system first | |||
| this.themeManager.init(); | |||
| this.state.currentTheme = this.themeManager.current(); | |||
| // Inject theme toggle if missing | |||
| setTimeout(() => { | |||
| this.injectThemeToggle(); | |||
| }, 100); | |||
| } | |||
| /** | |||
| * Inject theme toggle button | |||
| */ | |||
| private injectThemeToggle(): void { | |||
| const navRight = DOM.select('.nav-right'); | |||
| if (navRight && !DOM.exists('#theme-toggle')) { | |||
| const li = document.createElement('li'); | |||
| li.className = 'theme-toggle d-flex ai-c'; | |||
| li.innerHTML = ` | |||
| <div class="form-check form-switch d-flex ai-c" style="margin: 0; padding: 0;"> | |||
| <label class="form-check-label me-2 text-nowrap c-grey-700" for="theme-toggle" style="font-size: 12px; margin-right: 8px;"> | |||
| <i class="ti-sun" style="margin-right: 4px;"></i><span class="theme-label">Light</span> | |||
| </label> | |||
| <input class="form-check-input" type="checkbox" id="theme-toggle" style="margin: 0;"> | |||
| <label class="form-check-label ms-2 text-nowrap c-grey-700" for="theme-toggle" style="font-size: 12px; margin-left: 8px;"> | |||
| <span class="theme-label">Dark</span><i class="ti-moon" style="margin-left: 4px;"></i> | |||
| </label> | |||
| </div> | |||
| `; | |||
| // Insert before user dropdown (last item) | |||
| const lastItem = navRight.querySelector('li:last-child'); | |||
| if (lastItem && lastItem.parentNode === navRight) { | |||
| navRight.insertBefore(li, lastItem); | |||
| } else { | |||
| navRight.appendChild(li); | |||
| } | |||
| this.setupThemeToggle(); | |||
| this.log('Theme toggle injected'); | |||
| } | |||
| } | |||
| /** | |||
| * Setup theme toggle functionality | |||
| */ | |||
| private setupThemeToggle(): void { | |||
| const toggle = DOM.select('#theme-toggle') as HTMLInputElement; | |||
| if (!toggle) return; | |||
| // Set initial state | |||
| toggle.checked = this.state.currentTheme === 'dark'; | |||
| // Add change handler | |||
| const changeHandler = (): void => { | |||
| const newTheme = toggle.checked ? 'dark' : 'light'; | |||
| const previousTheme = this.state.currentTheme; | |||
| this.themeManager.apply(newTheme); | |||
| this.state.currentTheme = newTheme; | |||
| this.dispatchEvent('themeChanged', { theme: newTheme, previousTheme }); | |||
| }; | |||
| DOM.on(toggle, 'change', changeHandler); | |||
| this.eventHandlers.set('theme-toggle', changeHandler); | |||
| // Listen for theme changes from other sources | |||
| const themeChangeHandler = (event: CustomEvent): void => { | |||
| const newTheme = event.detail.theme; | |||
| toggle.checked = newTheme === 'dark'; | |||
| this.state.currentTheme = newTheme; | |||
| // Update charts when theme changes | |||
| const charts = this.getComponent('charts') as ChartComponent; | |||
| if (charts && typeof charts.redrawCharts === 'function') { | |||
| charts.redrawCharts(); | |||
| } | |||
| }; | |||
| window.addEventListener('adminator:themeChanged', themeChangeHandler as EventListener); | |||
| this.eventHandlers.set('theme-change', themeChangeHandler as EventListener); | |||
| } | |||
| /** | |||
| * Initialize mobile-specific enhancements | |||
| */ | |||
| private initMobileEnhancements(): void { | |||
| if (!this.options.mobile?.enhanced) return; | |||
| this.log('Initializing mobile enhancements...'); | |||
| this.enhanceMobileDropdowns(); | |||
| this.enhanceMobileSearch(); | |||
| // Prevent horizontal scroll on mobile | |||
| if (this.state.isMobile) { | |||
| document.body.style.overflowX = 'hidden'; | |||
| } | |||
| } | |||
| /** | |||
| * Setup global event listeners | |||
| */ | |||
| private setupGlobalEvents(): void { | |||
| // Global click handler | |||
| const globalClickHandler = (event: Event): void => { | |||
| this.handleGlobalClick(event); | |||
| }; | |||
| DOM.on(document, 'click', globalClickHandler); | |||
| this.eventHandlers.set('global-click', globalClickHandler); | |||
| // Window resize handler with debouncing | |||
| const resizeHandler = (): void => { | |||
| if (this.resizeTimeout) { | |||
| clearTimeout(this.resizeTimeout); | |||
| } | |||
| this.resizeTimeout = window.setTimeout(() => { | |||
| this.handleResize(); | |||
| }, 250); | |||
| }; | |||
| DOM.on(window, 'resize', resizeHandler); | |||
| this.eventHandlers.set('resize', resizeHandler); | |||
| this.log('Global event listeners set up'); | |||
| } | |||
| /** | |||
| * Handle window resize events | |||
| */ | |||
| private handleResize(): void { | |||
| const wasMobile = this.state.isMobile; | |||
| this.state.isMobile = this.checkMobileState(); | |||
| if (wasMobile !== this.state.isMobile) { | |||
| this.dispatchEvent('mobileStateChanged', { isMobile: this.state.isMobile }); | |||
| } | |||
| this.log('Window resized, updating mobile features'); | |||
| // Close all mobile-specific overlays when switching to desktop | |||
| if (!this.state.isMobile) { | |||
| document.body.style.overflow = ''; | |||
| document.body.style.overflowX = ''; | |||
| // Close dropdowns | |||
| const dropdowns = DOM.selectAll('.nav-right .dropdown'); | |||
| dropdowns.forEach(dropdown => { | |||
| dropdown.classList.remove('show'); | |||
| const menu = dropdown.querySelector('.dropdown-menu'); | |||
| if (menu) menu.classList.remove('show'); | |||
| }); | |||
| // Close search | |||
| this.closeSearch(); | |||
| } else { | |||
| // Re-enable mobile overflow protection | |||
| document.body.style.overflowX = 'hidden'; | |||
| } | |||
| // Re-apply mobile enhancements | |||
| if (this.options.mobile?.enhanced) { | |||
| this.enhanceMobileDropdowns(); | |||
| this.enhanceMobileSearch(); | |||
| } | |||
| } | |||
| /** | |||
| * Handle global click events | |||
| */ | |||
| private handleGlobalClick(event: Event): void { | |||
| const target = event.target as HTMLElement; | |||
| // Close mobile dropdowns when clicking outside | |||
| if (!target.closest('.dropdown')) { | |||
| const dropdowns = DOM.selectAll('.nav-right .dropdown'); | |||
| dropdowns.forEach(dropdown => { | |||
| dropdown.classList.remove('show'); | |||
| const menu = dropdown.querySelector('.dropdown-menu'); | |||
| if (menu) menu.classList.remove('show'); | |||
| }); | |||
| document.body.style.overflow = ''; | |||
| } | |||
| // Close search when clicking outside | |||
| if (!target.closest('.search-box') && !target.closest('.search-input')) { | |||
| this.closeSearch(); | |||
| } | |||
| } | |||
| /** | |||
| * Check if we're on a mobile device | |||
| */ | |||
| private checkMobileState(): boolean { | |||
| return window.innerWidth <= 768; | |||
| } | |||
| /** | |||
| * Enhanced mobile dropdown handling | |||
| */ | |||
| private enhanceMobileDropdowns(): void { | |||
| if (!this.state.isMobile || this.options.mobile?.disableDropdowns) return; | |||
| const dropdowns = DOM.selectAll('.nav-right .dropdown'); | |||
| dropdowns.forEach(dropdown => { | |||
| const toggle = dropdown.querySelector('.dropdown-toggle') as HTMLElement; | |||
| const menu = dropdown.querySelector('.dropdown-menu') as HTMLElement; | |||
| if (toggle && menu) { | |||
| // Remove existing listeners to prevent duplicates | |||
| const newToggle = toggle.cloneNode(true) as HTMLElement; | |||
| toggle.replaceWith(newToggle); | |||
| // Add click functionality for mobile dropdowns | |||
| DOM.on(newToggle, 'click', (e: Event) => { | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| // Close search if open | |||
| this.closeSearch(); | |||
| // Close other dropdowns first | |||
| dropdowns.forEach(otherDropdown => { | |||
| if (otherDropdown !== dropdown) { | |||
| otherDropdown.classList.remove('show'); | |||
| const otherMenu = otherDropdown.querySelector('.dropdown-menu'); | |||
| if (otherMenu) otherMenu.classList.remove('show'); | |||
| } | |||
| }); | |||
| // Toggle current dropdown | |||
| const isOpen = dropdown.classList.contains('show'); | |||
| if (isOpen) { | |||
| dropdown.classList.remove('show'); | |||
| menu.classList.remove('show'); | |||
| document.body.style.overflow = ''; | |||
| document.body.classList.remove('mobile-menu-open'); | |||
| } else { | |||
| dropdown.classList.add('show'); | |||
| menu.classList.add('show'); | |||
| document.body.style.overflow = 'hidden'; | |||
| document.body.classList.add('mobile-menu-open'); | |||
| } | |||
| }); | |||
| // Enhanced mobile close button functionality | |||
| DOM.on(menu, 'click', (e: Event) => { | |||
| const rect = menu.getBoundingClientRect(); | |||
| const clickY = (e as MouseEvent).clientY - rect.top; | |||
| // If clicked in top 50px (close button area) | |||
| if (clickY <= 50) { | |||
| dropdown.classList.remove('show'); | |||
| menu.classList.remove('show'); | |||
| document.body.style.overflow = ''; | |||
| document.body.classList.remove('mobile-menu-open'); | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| } | |||
| }); | |||
| } | |||
| }); | |||
| // Close dropdowns on escape key | |||
| const escapeHandler = (e: Event): void => { | |||
| const keyEvent = e as KeyboardEvent; | |||
| if (keyEvent.key === 'Escape') { | |||
| dropdowns.forEach(dropdown => { | |||
| dropdown.classList.remove('show'); | |||
| const menu = dropdown.querySelector('.dropdown-menu'); | |||
| if (menu) menu.classList.remove('show'); | |||
| }); | |||
| document.body.style.overflow = ''; | |||
| document.body.classList.remove('mobile-menu-open'); | |||
| } | |||
| }; | |||
| DOM.on(document, 'keydown', escapeHandler); | |||
| } | |||
| /** | |||
| * Enhanced mobile search handling | |||
| */ | |||
| private enhanceMobileSearch(): void { | |||
| if (!this.options.mobile?.fullWidthSearch) return; | |||
| const searchBox = DOM.select('.search-box') as HTMLElement; | |||
| const searchInput = DOM.select('.search-input') as HTMLElement; | |||
| if (searchBox && searchInput) { | |||
| const searchToggle = searchBox.querySelector('a') as HTMLAnchorElement; | |||
| const searchField = searchInput.querySelector('input') as HTMLInputElement; | |||
| if (searchToggle && searchField) { | |||
| // Remove existing listeners to prevent duplication | |||
| const newSearchToggle = searchToggle.cloneNode(true) as HTMLAnchorElement; | |||
| searchToggle.replaceWith(newSearchToggle); | |||
| DOM.on(newSearchToggle, 'click', (e: Event) => { | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| // Close any open dropdowns first | |||
| const dropdowns = DOM.selectAll('.nav-right .dropdown'); | |||
| dropdowns.forEach(dropdown => { | |||
| dropdown.classList.remove('show'); | |||
| const menu = dropdown.querySelector('.dropdown-menu'); | |||
| if (menu) menu.classList.remove('show'); | |||
| }); | |||
| // Toggle search state | |||
| const isActive = searchInput.classList.contains('active'); | |||
| const searchIcon = newSearchToggle.querySelector('i') as HTMLElement; | |||
| if (isActive) { | |||
| this.closeSearch(); | |||
| } else { | |||
| this.openSearch(searchField, searchIcon); | |||
| } | |||
| }); | |||
| // Handle search input | |||
| DOM.on(searchField, 'keypress', (e: Event) => { | |||
| const keyEvent = e as KeyboardEvent; | |||
| if (keyEvent.key === 'Enter') { | |||
| keyEvent.preventDefault(); | |||
| const query = searchField.value.trim(); | |||
| if (query) { | |||
| this.handleSearch(query); | |||
| } | |||
| } | |||
| }); | |||
| } | |||
| } | |||
| } | |||
| /** | |||
| * Open search interface | |||
| */ | |||
| private openSearch(searchField: HTMLInputElement, searchIcon: HTMLElement): void { | |||
| const searchInput = DOM.select('.search-input') as HTMLElement; | |||
| searchInput.classList.add('active'); | |||
| document.body.classList.add('search-open'); | |||
| // Change icon to close | |||
| if (searchIcon) { | |||
| searchIcon.className = 'ti-close'; | |||
| } | |||
| // Focus the input after a short delay | |||
| setTimeout(() => { | |||
| searchField.focus(); | |||
| }, 100); | |||
| } | |||
| /** | |||
| * Close search interface | |||
| */ | |||
| private closeSearch(): void { | |||
| const searchBox = DOM.select('.search-box') as HTMLElement; | |||
| const searchInput = DOM.select('.search-input') as HTMLElement; | |||
| if (searchBox && searchInput) { | |||
| searchInput.classList.remove('active'); | |||
| document.body.classList.remove('search-open'); | |||
| document.body.classList.remove('mobile-menu-open'); | |||
| // Reset icon | |||
| const searchIcon = searchBox.querySelector('i') as HTMLElement; | |||
| if (searchIcon) { | |||
| searchIcon.className = 'ti-search'; | |||
| } | |||
| // Clear input | |||
| const searchField = searchInput.querySelector('input') as HTMLInputElement; | |||
| if (searchField) { | |||
| searchField.value = ''; | |||
| searchField.blur(); | |||
| } | |||
| } | |||
| } | |||
| /** | |||
| * Handle search query | |||
| */ | |||
| private handleSearch(query: string): void { | |||
| this.log(`Search query: ${query}`); | |||
| // Implement your search logic here | |||
| // For demo, close search after "searching" | |||
| this.closeSearch(); | |||
| } | |||
| /** | |||
| * Add component to the application | |||
| */ | |||
| public addComponent(name: string, component: ComponentInterface): void { | |||
| this.state.components.set(name, component); | |||
| this.dispatchEvent('componentAdded', { name, component }); | |||
| this.log(`Component added: ${name}`); | |||
| } | |||
| /** | |||
| * Remove component from the application | |||
| */ | |||
| public removeComponent(name: string): void { | |||
| const component = this.state.components.get(name); | |||
| if (component) { | |||
| if (typeof component.destroy === 'function') { | |||
| component.destroy(); | |||
| } | |||
| this.state.components.delete(name); | |||
| this.dispatchEvent('componentRemoved', { name }); | |||
| this.log(`Component removed: ${name}`); | |||
| } | |||
| } | |||
| /** | |||
| * Get a component by name | |||
| */ | |||
| public getComponent(name: string): ComponentInterface | undefined { | |||
| return this.state.components.get(name); | |||
| } | |||
| /** | |||
| * Get all components | |||
| */ | |||
| public getComponents(): Map<string, ComponentInterface> { | |||
| return new Map(this.state.components); | |||
| } | |||
| /** | |||
| * Check if app is ready | |||
| */ | |||
| public isReady(): boolean { | |||
| return this.state.isInitialized; | |||
| } | |||
| /** | |||
| * Get current application state | |||
| */ | |||
| public getState(): Readonly<AdminatorAppState> { | |||
| return { | |||
| ...this.state, | |||
| components: new Map(this.state.components), | |||
| }; | |||
| } | |||
| /** | |||
| * Update application options | |||
| */ | |||
| public updateOptions(newOptions: Partial<AdminatorAppOptions>): void { | |||
| this.options = { ...this.options, ...newOptions }; | |||
| this.log('Options updated'); | |||
| } | |||
| /** | |||
| * Dispatch custom event | |||
| */ | |||
| private dispatchEvent<T extends keyof AdminatorAppEvents>( | |||
| type: T, | |||
| detail: AdminatorAppEvents[T]['detail'] | |||
| ): void { | |||
| const event = new CustomEvent(`adminator:${type}`, { | |||
| detail, | |||
| bubbles: true, | |||
| }); | |||
| window.dispatchEvent(event); | |||
| } | |||
| /** | |||
| * Log message if debugging is enabled | |||
| */ | |||
| private log(message: string): void { | |||
| if (this.options.debug) { | |||
| console.log(`[AdminatorApp] ${message}`); | |||
| } | |||
| } | |||
| /** | |||
| * Destroy the application | |||
| */ | |||
| public destroy(): void { | |||
| this.log('Destroying Adminator App'); | |||
| // Destroy all components | |||
| this.state.components.forEach((component, name) => { | |||
| if (typeof component.destroy === 'function') { | |||
| component.destroy(); | |||
| } | |||
| this.log(`Component destroyed: ${name}`); | |||
| }); | |||
| // Remove event listeners | |||
| this.eventHandlers.forEach((_, name) => { | |||
| // Note: We'd need to track which element each handler was attached to | |||
| // For now, we'll rely on the browser's garbage collection | |||
| this.log(`Event handler removed: ${name}`); | |||
| }); | |||
| // Clear state | |||
| this.state.components.clear(); | |||
| this.eventHandlers.clear(); | |||
| this.state.isInitialized = false; | |||
| // Clear timeout | |||
| if (this.resizeTimeout) { | |||
| clearTimeout(this.resizeTimeout); | |||
| this.resizeTimeout = null; | |||
| } | |||
| } | |||
| /** | |||
| * Refresh/reinitialize the application | |||
| */ | |||
| public refresh(): void { | |||
| this.log('Refreshing Adminator App'); | |||
| if (this.state.isInitialized) { | |||
| this.destroy(); | |||
| } | |||
| setTimeout(() => { | |||
| this.init(); | |||
| }, 100); | |||
| } | |||
| } | |||
| // Initialize the application | |||
| const app = new AdminatorApp({ | |||
| debug: process.env.NODE_ENV === 'development', | |||
| }); | |||
| // Make app globally available for debugging | |||
| window.AdminatorApp = app; | |||
| // Export for module usage | |||
| export default app; | |||
| @ -0,0 +1,388 @@ | |||
| /** | |||
| * Modern Sidebar Component with TypeScript | |||
| * Replaces jQuery-based sidebar functionality with vanilla JavaScript | |||
| */ | |||
| import type { ComponentInterface, SidebarOptions, SidebarState, AnimationOptions } from '../../../types'; | |||
| export interface SidebarEventDetail { | |||
| collapsed: boolean; | |||
| } | |||
| export interface SidebarToggleEvent extends CustomEvent { | |||
| detail: SidebarEventDetail; | |||
| } | |||
| declare global { | |||
| interface Window { | |||
| EVENT?: Event; | |||
| } | |||
| } | |||
| export class Sidebar implements ComponentInterface { | |||
| public name: string = 'Sidebar'; | |||
| public element: HTMLElement; | |||
| public options: SidebarOptions; | |||
| public isInitialized: boolean = false; | |||
| private sidebar: HTMLElement | null; | |||
| private sidebarMenu: HTMLElement | null; | |||
| private sidebarToggleLinks: NodeListOf<HTMLAnchorElement>; | |||
| private sidebarToggleById: HTMLElement | null; | |||
| private app: HTMLElement | null; | |||
| private state: SidebarState; | |||
| constructor(element?: HTMLElement, options: SidebarOptions = {}) { | |||
| this.element = element || document.body; | |||
| this.options = { | |||
| breakpoint: 768, | |||
| collapsible: true, | |||
| autoHide: true, | |||
| animation: true, | |||
| animationDuration: 200, | |||
| ...options, | |||
| }; | |||
| this.sidebar = document.querySelector('.sidebar'); | |||
| this.sidebarMenu = document.querySelector('.sidebar .sidebar-menu'); | |||
| this.sidebarToggleLinks = document.querySelectorAll('.sidebar-toggle a'); | |||
| this.sidebarToggleById = document.querySelector('#sidebar-toggle'); | |||
| this.app = document.querySelector('.app'); | |||
| this.state = { | |||
| isCollapsed: false, | |||
| isMobile: false, | |||
| activeMenu: null, | |||
| }; | |||
| this.init(); | |||
| } | |||
| /** | |||
| * Initialize the sidebar component | |||
| */ | |||
| public init(): void { | |||
| if (!this.sidebar || !this.sidebarMenu) { | |||
| console.warn('Sidebar: Required elements not found'); | |||
| return; | |||
| } | |||
| this.setupMenuToggle(); | |||
| this.setupSidebarToggle(); | |||
| this.setActiveLink(); | |||
| this.handleResize(); | |||
| this.setupEventListeners(); | |||
| this.isInitialized = true; | |||
| } | |||
| /** | |||
| * Destroy the sidebar component | |||
| */ | |||
| public destroy(): void { | |||
| this.removeEventListeners(); | |||
| this.isInitialized = false; | |||
| } | |||
| /** | |||
| * Setup dropdown menu functionality | |||
| */ | |||
| private setupMenuToggle(): void { | |||
| if (!this.sidebarMenu) return; | |||
| const menuLinks = this.sidebarMenu.querySelectorAll('li a'); | |||
| menuLinks.forEach(link => { | |||
| link.addEventListener('click', this.handleMenuClick.bind(this)); | |||
| }); | |||
| } | |||
| /** | |||
| * Handle menu item click | |||
| */ | |||
| private handleMenuClick(e: Event): void { | |||
| const link = e.target as HTMLAnchorElement; | |||
| const listItem = link.parentElement as HTMLLIElement; | |||
| const dropdownMenu = listItem?.querySelector('.dropdown-menu') as HTMLElement; | |||
| // If this is a regular navigation link (not dropdown), allow normal navigation | |||
| if (!dropdownMenu) { | |||
| return; | |||
| } | |||
| // Only prevent default for dropdown toggles | |||
| e.preventDefault(); | |||
| if (listItem.classList.contains('open')) { | |||
| this.closeDropdown(listItem, dropdownMenu); | |||
| } else { | |||
| this.closeAllDropdowns(); | |||
| this.openDropdown(listItem, dropdownMenu); | |||
| } | |||
| } | |||
| /** | |||
| * Open dropdown with smooth animation | |||
| */ | |||
| private openDropdown(listItem: HTMLLIElement, dropdownMenu: HTMLElement): void { | |||
| listItem.classList.add('open'); | |||
| dropdownMenu.style.display = 'block'; | |||
| dropdownMenu.style.height = '0px'; | |||
| dropdownMenu.style.overflow = 'hidden'; | |||
| // Get the natural height | |||
| const height = dropdownMenu.scrollHeight; | |||
| // Animate to full height | |||
| const animation = dropdownMenu.animate([ | |||
| { height: '0px' }, | |||
| { height: `${height}px` }, | |||
| ], { | |||
| duration: this.options.animationDuration, | |||
| easing: 'ease-out', | |||
| }); | |||
| animation.onfinish = (): void => { | |||
| dropdownMenu.style.height = 'auto'; | |||
| dropdownMenu.style.overflow = 'visible'; | |||
| }; | |||
| } | |||
| /** | |||
| * Close dropdown with smooth animation | |||
| */ | |||
| private closeDropdown(listItem: HTMLLIElement, dropdownMenu: HTMLElement): void { | |||
| const height = dropdownMenu.scrollHeight; | |||
| dropdownMenu.style.height = `${height}px`; | |||
| dropdownMenu.style.overflow = 'hidden'; | |||
| const animation = dropdownMenu.animate([ | |||
| { height: `${height}px` }, | |||
| { height: '0px' }, | |||
| ], { | |||
| duration: this.options.animationDuration, | |||
| easing: 'ease-in', | |||
| }); | |||
| animation.onfinish = (): void => { | |||
| listItem.classList.remove('open'); | |||
| dropdownMenu.style.display = 'none'; | |||
| dropdownMenu.style.height = ''; | |||
| dropdownMenu.style.overflow = ''; | |||
| }; | |||
| } | |||
| /** | |||
| * Close all open dropdowns | |||
| */ | |||
| private closeAllDropdowns(): void { | |||
| if (!this.sidebarMenu) return; | |||
| const openItems = this.sidebarMenu.querySelectorAll('li.open'); | |||
| openItems.forEach(item => { | |||
| const dropdownMenu = item.querySelector('.dropdown-menu') as HTMLElement; | |||
| if (dropdownMenu) { | |||
| this.closeDropdown(item as HTMLLIElement, dropdownMenu); | |||
| } | |||
| // Also remove the has-active-child class | |||
| item.classList.remove('has-active-child'); | |||
| }); | |||
| } | |||
| /** | |||
| * Setup sidebar toggle functionality | |||
| */ | |||
| private setupSidebarToggle(): void { | |||
| // Handle mobile sidebar toggle links (inside .sidebar-toggle divs) | |||
| this.sidebarToggleLinks.forEach(link => { | |||
| if (link && this.app) { | |||
| link.addEventListener('click', this.handleSidebarToggle.bind(this)); | |||
| } | |||
| }); | |||
| // Handle the main topbar sidebar toggle | |||
| if (this.sidebarToggleById && this.app) { | |||
| this.sidebarToggleById.addEventListener('click', this.handleSidebarToggle.bind(this)); | |||
| } | |||
| } | |||
| /** | |||
| * Handle sidebar toggle click | |||
| */ | |||
| private handleSidebarToggle(e: Event): void { | |||
| e.preventDefault(); | |||
| this.toggleSidebar(); | |||
| } | |||
| /** | |||
| * Toggle sidebar and handle resize events properly | |||
| */ | |||
| private toggleSidebar(): void { | |||
| if (!this.app) return; | |||
| const wasCollapsed = this.state.isCollapsed; | |||
| this.state.isCollapsed = !wasCollapsed; | |||
| this.app.classList.toggle('is-collapsed'); | |||
| // Dispatch custom event with proper typing | |||
| setTimeout(() => { | |||
| const event: SidebarToggleEvent = new CustomEvent('sidebar:toggle', { | |||
| detail: { collapsed: this.state.isCollapsed }, | |||
| }) as SidebarToggleEvent; | |||
| window.dispatchEvent(event); | |||
| // Still trigger resize for masonry but with a specific check | |||
| if (window.EVENT) { | |||
| window.dispatchEvent(window.EVENT); | |||
| } | |||
| }, this.options.animationDuration || 300); | |||
| } | |||
| /** | |||
| * Set active link based on current URL | |||
| */ | |||
| private setActiveLink(): void { | |||
| if (!this.sidebar) return; | |||
| // Remove active class from all nav items (including dropdown items) | |||
| const allNavItems = this.sidebar.querySelectorAll('.nav-item'); | |||
| allNavItems.forEach(item => { | |||
| item.classList.remove('actived'); | |||
| }); | |||
| // Close all dropdowns first | |||
| this.closeAllDropdowns(); | |||
| // Get current page filename | |||
| const currentPath = window.location.pathname; | |||
| const currentPage = currentPath.split('/').pop() || 'index.html'; | |||
| // Find and activate the correct nav item | |||
| const allLinks = this.sidebar.querySelectorAll('a[href]'); | |||
| allLinks.forEach(link => { | |||
| const href = link.getAttribute('href'); | |||
| if (!href || href === 'javascript:void(0);' || href === 'javascript:void(0)') return; | |||
| // Extract filename from href | |||
| const linkPage = href.split('/').pop(); | |||
| if (linkPage === currentPage) { | |||
| const navItem = link.closest('.nav-item') as HTMLElement; | |||
| if (navItem) { | |||
| navItem.classList.add('actived'); | |||
| this.state.activeMenu = linkPage || null; | |||
| // If this is inside a dropdown, handle parent dropdown specially | |||
| const parentDropdown = navItem.closest('.dropdown-menu') as HTMLElement; | |||
| if (parentDropdown) { | |||
| const parentDropdownItem = parentDropdown.closest('.nav-item.dropdown') as HTMLElement; | |||
| if (parentDropdownItem) { | |||
| // Open the parent dropdown | |||
| parentDropdownItem.classList.add('open'); | |||
| parentDropdown.style.display = 'block'; | |||
| // Add special styling to indicate parent has active child | |||
| parentDropdownItem.classList.add('has-active-child'); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| }); | |||
| } | |||
| /** | |||
| * Handle window resize | |||
| */ | |||
| private handleResize(): void { | |||
| this.state.isMobile = window.innerWidth <= (this.options.breakpoint || 768); | |||
| if (this.options.autoHide && this.state.isMobile) { | |||
| // Auto-hide logic for mobile | |||
| this.collapse(); | |||
| } | |||
| } | |||
| /** | |||
| * Setup event listeners | |||
| */ | |||
| private setupEventListeners(): void { | |||
| window.addEventListener('resize', this.handleResize.bind(this)); | |||
| } | |||
| /** | |||
| * Remove event listeners | |||
| */ | |||
| private removeEventListeners(): void { | |||
| window.removeEventListener('resize', this.handleResize.bind(this)); | |||
| } | |||
| /** | |||
| * Public method to refresh active links (useful for SPA navigation) | |||
| */ | |||
| public refreshActiveLink(): void { | |||
| this.setActiveLink(); | |||
| } | |||
| /** | |||
| * Public method to toggle sidebar programmatically | |||
| */ | |||
| public toggle(): void { | |||
| this.toggleSidebar(); | |||
| } | |||
| /** | |||
| * Public method to collapse sidebar | |||
| */ | |||
| public collapse(): void { | |||
| if (!this.app || this.state.isCollapsed) return; | |||
| this.state.isCollapsed = true; | |||
| this.app.classList.add('is-collapsed'); | |||
| } | |||
| /** | |||
| * Public method to expand sidebar | |||
| */ | |||
| public expand(): void { | |||
| if (!this.app || !this.state.isCollapsed) return; | |||
| this.state.isCollapsed = false; | |||
| this.app.classList.remove('is-collapsed'); | |||
| } | |||
| /** | |||
| * Public method to check if sidebar is collapsed | |||
| */ | |||
| public isCollapsed(): boolean { | |||
| return this.state.isCollapsed; | |||
| } | |||
| /** | |||
| * Get current sidebar state | |||
| */ | |||
| public getState(): SidebarState { | |||
| return { ...this.state }; | |||
| } | |||
| /** | |||
| * Update sidebar options | |||
| */ | |||
| public updateOptions(newOptions: Partial<SidebarOptions>): void { | |||
| this.options = { ...this.options, ...newOptions }; | |||
| } | |||
| /** | |||
| * Get current options | |||
| */ | |||
| public getOptions(): SidebarOptions { | |||
| return { ...this.options }; | |||
| } | |||
| } | |||
| export default Sidebar; | |||
| @ -0,0 +1,707 @@ | |||
| /** | |||
| * DataTable Implementation with TypeScript | |||
| * Vanilla JavaScript DataTable with sorting, searching, and pagination | |||
| */ | |||
| import type { ComponentInterface } from '../../types'; | |||
| // Type definitions for DataTable | |||
| export interface DataTableOptions { | |||
| sortable?: boolean; | |||
| searchable?: boolean; | |||
| pagination?: boolean; | |||
| pageSize?: number; | |||
| responsive?: boolean; | |||
| striped?: boolean; | |||
| bordered?: boolean; | |||
| hover?: boolean; | |||
| } | |||
| export interface DataTableColumn { | |||
| title: string; | |||
| data: string | number; | |||
| sortable?: boolean; | |||
| searchable?: boolean; | |||
| width?: string; | |||
| className?: string; | |||
| render?: (data: any, row: any[], index: number) => string; | |||
| } | |||
| export interface DataTableData { | |||
| columns: DataTableColumn[]; | |||
| rows: any[][]; | |||
| } | |||
| export interface DataTableState { | |||
| currentPage: number; | |||
| sortColumn: number | null; | |||
| sortDirection: 'asc' | 'desc'; | |||
| searchQuery: string; | |||
| filteredData: any[][]; | |||
| totalPages: number; | |||
| } | |||
| export type SortDirection = 'asc' | 'desc'; | |||
| declare global { | |||
| interface HTMLTableElement { | |||
| dataTableInstance?: VanillaDataTable; | |||
| } | |||
| } | |||
| // Enhanced DataTable implementation | |||
| export class VanillaDataTable implements ComponentInterface { | |||
| public name: string = 'VanillaDataTable'; | |||
| public element: HTMLTableElement; | |||
| public options: DataTableOptions; | |||
| public isInitialized: boolean = false; | |||
| private originalData: any[][] = []; | |||
| private filteredData: any[][] = []; | |||
| private state: DataTableState; | |||
| private wrapper: HTMLElement | null = null; | |||
| private searchInput: HTMLInputElement | null = null; | |||
| private infoElement: HTMLElement | null = null; | |||
| private paginationElement: HTMLElement | null = null; | |||
| constructor(element: HTMLTableElement, options: DataTableOptions = {}) { | |||
| this.element = element; | |||
| this.options = { | |||
| sortable: true, | |||
| searchable: true, | |||
| pagination: true, | |||
| pageSize: 10, | |||
| responsive: true, | |||
| striped: true, | |||
| bordered: true, | |||
| hover: true, | |||
| ...options, | |||
| }; | |||
| this.state = { | |||
| currentPage: 1, | |||
| sortColumn: null, | |||
| sortDirection: 'asc', | |||
| searchQuery: '', | |||
| filteredData: [], | |||
| totalPages: 0, | |||
| }; | |||
| this.init(); | |||
| } | |||
| public init(): void { | |||
| this.extractData(); | |||
| this.createControls(); | |||
| this.applyStyles(); | |||
| this.bindEvents(); | |||
| this.render(); | |||
| this.isInitialized = true; | |||
| } | |||
| public destroy(): void { | |||
| if (this.wrapper && this.wrapper.parentNode) { | |||
| this.wrapper.parentNode.replaceChild(this.element, this.wrapper); | |||
| } | |||
| this.isInitialized = false; | |||
| } | |||
| private extractData(): void { | |||
| const tbody = this.element.querySelector('tbody'); | |||
| if (!tbody) return; | |||
| const rows = tbody.querySelectorAll('tr'); | |||
| this.originalData = Array.from(rows).map(row => { | |||
| const cells = row.querySelectorAll('td'); | |||
| return Array.from(cells).map(cell => cell.textContent?.trim() || ''); | |||
| }); | |||
| this.filteredData = [...this.originalData]; | |||
| this.state.filteredData = this.filteredData; | |||
| } | |||
| private createControls(): void { | |||
| const wrapper = document.createElement('div'); | |||
| wrapper.className = 'datatable-wrapper'; | |||
| // Create top controls container | |||
| const topControls = document.createElement('div'); | |||
| topControls.className = 'datatable-top-controls'; | |||
| // Create search input | |||
| if (this.options.searchable) { | |||
| const searchWrapper = document.createElement('div'); | |||
| searchWrapper.className = 'datatable-search'; | |||
| const searchLabel = document.createElement('label'); | |||
| searchLabel.textContent = 'Search: '; | |||
| this.searchInput = document.createElement('input'); | |||
| this.searchInput.type = 'text'; | |||
| this.searchInput.className = 'form-control'; | |||
| this.searchInput.placeholder = 'Search...'; | |||
| searchLabel.appendChild(this.searchInput); | |||
| searchWrapper.appendChild(searchLabel); | |||
| topControls.appendChild(searchWrapper); | |||
| } | |||
| // Create info display | |||
| if (this.options.pagination) { | |||
| this.infoElement = document.createElement('div'); | |||
| this.infoElement.className = 'datatable-info'; | |||
| topControls.appendChild(this.infoElement); | |||
| } | |||
| wrapper.appendChild(topControls); | |||
| // Wrap the table | |||
| if (this.element.parentNode) { | |||
| this.element.parentNode.insertBefore(wrapper, this.element); | |||
| } | |||
| wrapper.appendChild(this.element); | |||
| // Create pagination controls | |||
| if (this.options.pagination) { | |||
| this.paginationElement = document.createElement('div'); | |||
| this.paginationElement.className = 'datatable-pagination'; | |||
| wrapper.appendChild(this.paginationElement); | |||
| } | |||
| this.wrapper = wrapper; | |||
| } | |||
| private applyStyles(): void { | |||
| // Apply Bootstrap-like styles | |||
| const classes = ['table']; | |||
| if (this.options.striped) classes.push('table-striped'); | |||
| if (this.options.bordered) classes.push('table-bordered'); | |||
| if (this.options.hover) classes.push('table-hover'); | |||
| if (this.options.responsive) { | |||
| const responsiveWrapper = document.createElement('div'); | |||
| responsiveWrapper.className = 'table-responsive'; | |||
| if (this.element.parentNode) { | |||
| this.element.parentNode.insertBefore(responsiveWrapper, this.element); | |||
| responsiveWrapper.appendChild(this.element); | |||
| } | |||
| } | |||
| this.element.className = classes.join(' '); | |||
| // Add custom styles | |||
| this.injectStyles(); | |||
| } | |||
| private injectStyles(): void { | |||
| const styleId = 'datatable-styles'; | |||
| if (document.getElementById(styleId)) return; | |||
| const style = document.createElement('style'); | |||
| style.id = styleId; | |||
| style.textContent = ` | |||
| .datatable-wrapper { | |||
| margin: 20px 0; | |||
| } | |||
| .datatable-top-controls { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 15px; | |||
| flex-wrap: wrap; | |||
| gap: 10px; | |||
| } | |||
| .datatable-search { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 8px; | |||
| } | |||
| .datatable-search label { | |||
| margin: 0; | |||
| font-weight: 500; | |||
| } | |||
| .datatable-search input { | |||
| width: 250px; | |||
| padding: 6px 12px; | |||
| border: 1px solid var(--c-border, #dee2e6); | |||
| border-radius: 4px; | |||
| font-size: 14px; | |||
| } | |||
| .datatable-info { | |||
| color: var(--c-text-muted, #6c757d); | |||
| font-size: 14px; | |||
| margin: 0; | |||
| } | |||
| .datatable-pagination { | |||
| margin-top: 15px; | |||
| display: flex; | |||
| justify-content: center; | |||
| align-items: center; | |||
| gap: 4px; | |||
| flex-wrap: wrap; | |||
| } | |||
| .datatable-pagination button { | |||
| background: var(--c-bkg-card, #fff); | |||
| border: 1px solid var(--c-border, #dee2e6); | |||
| color: var(--c-text-base, #333); | |||
| padding: 8px 12px; | |||
| cursor: pointer; | |||
| border-radius: 4px; | |||
| font-size: 14px; | |||
| transition: all 0.2s ease; | |||
| min-width: 40px; | |||
| } | |||
| .datatable-pagination button:hover:not(:disabled) { | |||
| background: var(--c-primary, #007bff); | |||
| border-color: var(--c-primary, #007bff); | |||
| color: white; | |||
| } | |||
| .datatable-pagination button.active { | |||
| background: var(--c-primary, #007bff); | |||
| border-color: var(--c-primary, #007bff); | |||
| color: white; | |||
| } | |||
| .datatable-pagination button:disabled { | |||
| opacity: 0.6; | |||
| cursor: not-allowed; | |||
| background: var(--c-bkg-muted, #f8f9fa); | |||
| } | |||
| .datatable-sort { | |||
| cursor: pointer; | |||
| user-select: none; | |||
| position: relative; | |||
| padding-right: 20px !important; | |||
| transition: background-color 0.2s ease; | |||
| } | |||
| .datatable-sort:hover { | |||
| background: var(--c-bkg-hover, #f8f9fa); | |||
| } | |||
| .datatable-sort::after { | |||
| content: '↕'; | |||
| position: absolute; | |||
| right: 8px; | |||
| top: 50%; | |||
| transform: translateY(-50%); | |||
| opacity: 0.5; | |||
| font-size: 12px; | |||
| } | |||
| .datatable-sort.asc::after { | |||
| content: '↑'; | |||
| opacity: 1; | |||
| color: var(--c-primary, #007bff); | |||
| } | |||
| .datatable-sort.desc::after { | |||
| content: '↓'; | |||
| opacity: 1; | |||
| color: var(--c-primary, #007bff); | |||
| } | |||
| .datatable-no-results { | |||
| text-align: center; | |||
| color: var(--c-text-muted, #6c757d); | |||
| font-style: italic; | |||
| padding: 20px; | |||
| } | |||
| @media (max-width: 768px) { | |||
| .datatable-top-controls { | |||
| flex-direction: column; | |||
| align-items: stretch; | |||
| } | |||
| .datatable-search input { | |||
| width: 100%; | |||
| } | |||
| .datatable-pagination { | |||
| justify-content: center; | |||
| } | |||
| .datatable-pagination button { | |||
| padding: 6px 10px; | |||
| font-size: 13px; | |||
| } | |||
| } | |||
| `; | |||
| document.head.appendChild(style); | |||
| } | |||
| private bindEvents(): void { | |||
| // Search functionality | |||
| if (this.options.searchable && this.searchInput) { | |||
| this.searchInput.addEventListener('input', (e) => { | |||
| const target = e.target as HTMLInputElement; | |||
| this.search(target.value); | |||
| }); | |||
| } | |||
| // Sorting functionality | |||
| if (this.options.sortable) { | |||
| const headers = this.element.querySelectorAll<HTMLTableCellElement>('thead th'); | |||
| headers.forEach((header, index) => { | |||
| header.classList.add('datatable-sort'); | |||
| header.addEventListener('click', () => { | |||
| this.sort(index); | |||
| }); | |||
| header.setAttribute('tabindex', '0'); | |||
| header.setAttribute('role', 'button'); | |||
| header.setAttribute('aria-label', `Sort by ${header.textContent}`); | |||
| }); | |||
| } | |||
| } | |||
| public search(query: string): void { | |||
| this.state.searchQuery = query; | |||
| if (!query.trim()) { | |||
| this.filteredData = [...this.originalData]; | |||
| } else { | |||
| const searchTerm = query.toLowerCase().trim(); | |||
| this.filteredData = this.originalData.filter(row => | |||
| row.some(cell => | |||
| cell.toString().toLowerCase().includes(searchTerm) | |||
| ) | |||
| ); | |||
| } | |||
| this.state.filteredData = this.filteredData; | |||
| this.state.currentPage = 1; | |||
| this.render(); | |||
| } | |||
| public sort(columnIndex: number): void { | |||
| if (this.state.sortColumn === columnIndex) { | |||
| this.state.sortDirection = this.state.sortDirection === 'asc' ? 'desc' : 'asc'; | |||
| } else { | |||
| this.state.sortColumn = columnIndex; | |||
| this.state.sortDirection = 'asc'; | |||
| } | |||
| this.filteredData.sort((a, b) => { | |||
| const aVal = a[columnIndex]; | |||
| const bVal = b[columnIndex]; | |||
| // Try to parse as numbers | |||
| const aNum = parseFloat(aVal); | |||
| const bNum = parseFloat(bVal); | |||
| let comparison = 0; | |||
| if (!isNaN(aNum) && !isNaN(bNum)) { | |||
| comparison = aNum - bNum; | |||
| } else { | |||
| // Try to parse as dates | |||
| const aDate = new Date(aVal); | |||
| const bDate = new Date(bVal); | |||
| if (aDate.getTime() && bDate.getTime()) { | |||
| comparison = aDate.getTime() - bDate.getTime(); | |||
| } else { | |||
| comparison = aVal.toString().localeCompare(bVal.toString()); | |||
| } | |||
| } | |||
| return this.state.sortDirection === 'asc' ? comparison : -comparison; | |||
| }); | |||
| this.updateSortHeaders(); | |||
| this.render(); | |||
| } | |||
| private updateSortHeaders(): void { | |||
| const headers = this.element.querySelectorAll<HTMLTableCellElement>('thead th'); | |||
| headers.forEach((header, index) => { | |||
| header.classList.remove('asc', 'desc'); | |||
| if (index === this.state.sortColumn) { | |||
| header.classList.add(this.state.sortDirection); | |||
| } | |||
| }); | |||
| } | |||
| public render(): void { | |||
| const tbody = this.element.querySelector('tbody'); | |||
| if (!tbody) return; | |||
| const startIndex = (this.state.currentPage - 1) * this.options.pageSize!; | |||
| const endIndex = startIndex + this.options.pageSize!; | |||
| const pageData = this.filteredData.slice(startIndex, endIndex); | |||
| // Clear tbody | |||
| tbody.innerHTML = ''; | |||
| if (pageData.length === 0) { | |||
| // Show no results message | |||
| const noResultsRow = document.createElement('tr'); | |||
| const noResultsCell = document.createElement('td'); | |||
| noResultsCell.colSpan = this.getColumnCount(); | |||
| noResultsCell.className = 'datatable-no-results'; | |||
| noResultsCell.textContent = this.state.searchQuery ? | |||
| 'No matching records found' : 'No data available'; | |||
| noResultsRow.appendChild(noResultsCell); | |||
| tbody.appendChild(noResultsRow); | |||
| } else { | |||
| // Add rows | |||
| pageData.forEach((rowData, rowIndex) => { | |||
| const row = document.createElement('tr'); | |||
| rowData.forEach((cellData, colIndex) => { | |||
| const cell = document.createElement('td'); | |||
| cell.textContent = cellData.toString(); | |||
| row.appendChild(cell); | |||
| }); | |||
| tbody.appendChild(row); | |||
| }); | |||
| } | |||
| // Update pagination | |||
| if (this.options.pagination) { | |||
| this.updatePagination(); | |||
| } | |||
| // Update info | |||
| this.updateInfo(); | |||
| } | |||
| private getColumnCount(): number { | |||
| const headerRow = this.element.querySelector('thead tr'); | |||
| return headerRow ? headerRow.querySelectorAll('th').length : 0; | |||
| } | |||
| private updatePagination(): void { | |||
| if (!this.paginationElement) return; | |||
| this.state.totalPages = Math.ceil(this.filteredData.length / this.options.pageSize!); | |||
| this.paginationElement.innerHTML = ''; | |||
| if (this.state.totalPages <= 1) return; | |||
| // Previous button | |||
| const prevBtn = this.createPaginationButton('Previous', () => { | |||
| if (this.state.currentPage > 1) { | |||
| this.state.currentPage--; | |||
| this.render(); | |||
| } | |||
| }); | |||
| prevBtn.disabled = this.state.currentPage === 1; | |||
| this.paginationElement.appendChild(prevBtn); | |||
| // Calculate page range to show | |||
| const maxButtons = 5; | |||
| let startPage = Math.max(1, this.state.currentPage - Math.floor(maxButtons / 2)); | |||
| let endPage = Math.min(this.state.totalPages, startPage + maxButtons - 1); | |||
| // Adjust if we're at the end | |||
| if (endPage - startPage + 1 < maxButtons) { | |||
| startPage = Math.max(1, endPage - maxButtons + 1); | |||
| } | |||
| // First page if not in range | |||
| if (startPage > 1) { | |||
| const firstBtn = this.createPaginationButton('1', () => { | |||
| this.state.currentPage = 1; | |||
| this.render(); | |||
| }); | |||
| this.paginationElement.appendChild(firstBtn); | |||
| if (startPage > 2) { | |||
| const ellipsis = document.createElement('span'); | |||
| ellipsis.textContent = '...'; | |||
| ellipsis.className = 'pagination-ellipsis'; | |||
| this.paginationElement.appendChild(ellipsis); | |||
| } | |||
| } | |||
| // Page numbers | |||
| for (let i = startPage; i <= endPage; i++) { | |||
| const pageBtn = this.createPaginationButton(i.toString(), () => { | |||
| this.state.currentPage = i; | |||
| this.render(); | |||
| }); | |||
| pageBtn.classList.toggle('active', i === this.state.currentPage); | |||
| this.paginationElement.appendChild(pageBtn); | |||
| } | |||
| // Last page if not in range | |||
| if (endPage < this.state.totalPages) { | |||
| if (endPage < this.state.totalPages - 1) { | |||
| const ellipsis = document.createElement('span'); | |||
| ellipsis.textContent = '...'; | |||
| ellipsis.className = 'pagination-ellipsis'; | |||
| this.paginationElement.appendChild(ellipsis); | |||
| } | |||
| const lastBtn = this.createPaginationButton(this.state.totalPages.toString(), () => { | |||
| this.state.currentPage = this.state.totalPages; | |||
| this.render(); | |||
| }); | |||
| this.paginationElement.appendChild(lastBtn); | |||
| } | |||
| // Next button | |||
| const nextBtn = this.createPaginationButton('Next', () => { | |||
| if (this.state.currentPage < this.state.totalPages) { | |||
| this.state.currentPage++; | |||
| this.render(); | |||
| } | |||
| }); | |||
| nextBtn.disabled = this.state.currentPage === this.state.totalPages; | |||
| this.paginationElement.appendChild(nextBtn); | |||
| } | |||
| private createPaginationButton(text: string, onClick: () => void): HTMLButtonElement { | |||
| const button = document.createElement('button'); | |||
| button.textContent = text; | |||
| button.addEventListener('click', onClick); | |||
| return button; | |||
| } | |||
| private updateInfo(): void { | |||
| if (!this.infoElement) return; | |||
| const startIndex = (this.state.currentPage - 1) * this.options.pageSize! + 1; | |||
| const endIndex = Math.min(startIndex + this.options.pageSize! - 1, this.filteredData.length); | |||
| const total = this.filteredData.length; | |||
| const originalTotal = this.originalData.length; | |||
| if (total === 0) { | |||
| this.infoElement.textContent = 'No entries to show'; | |||
| } else if (total === originalTotal) { | |||
| this.infoElement.textContent = `Showing ${startIndex} to ${endIndex} of ${total} entries`; | |||
| } else { | |||
| this.infoElement.textContent = `Showing ${startIndex} to ${endIndex} of ${total} entries (filtered from ${originalTotal} total entries)`; | |||
| } | |||
| } | |||
| // Public API methods | |||
| public goToPage(page: number): void { | |||
| if (page >= 1 && page <= this.state.totalPages) { | |||
| this.state.currentPage = page; | |||
| this.render(); | |||
| } | |||
| } | |||
| public setPageSize(size: number): void { | |||
| this.options.pageSize = size; | |||
| this.state.currentPage = 1; | |||
| this.render(); | |||
| } | |||
| public getState(): Readonly<DataTableState> { | |||
| return { ...this.state }; | |||
| } | |||
| public refresh(): void { | |||
| this.extractData(); | |||
| this.state.currentPage = 1; | |||
| this.render(); | |||
| } | |||
| public clear(): void { | |||
| this.originalData = []; | |||
| this.filteredData = []; | |||
| this.state.currentPage = 1; | |||
| this.render(); | |||
| } | |||
| } | |||
| // DataTable Manager | |||
| export class DataTableManager { | |||
| private instances: Map<string, VanillaDataTable> = new Map(); | |||
| public initialize(selector: string = '#dataTable', options: DataTableOptions = {}): VanillaDataTable | null { | |||
| const element = document.querySelector<HTMLTableElement>(selector); | |||
| if (!element) { | |||
| // Silently return null if element doesn't exist (normal for pages without tables) | |||
| return null; | |||
| } | |||
| // Clean up existing instance | |||
| if (element.dataTableInstance) { | |||
| element.dataTableInstance.destroy(); | |||
| } | |||
| // Create new instance | |||
| const dataTable = new VanillaDataTable(element, options); | |||
| element.dataTableInstance = dataTable; | |||
| // Store in manager | |||
| this.instances.set(selector, dataTable); | |||
| return dataTable; | |||
| } | |||
| public getInstance(selector: string): VanillaDataTable | undefined { | |||
| return this.instances.get(selector); | |||
| } | |||
| public destroyInstance(selector: string): void { | |||
| const instance = this.instances.get(selector); | |||
| if (instance) { | |||
| instance.destroy(); | |||
| this.instances.delete(selector); | |||
| } | |||
| } | |||
| public destroyAll(): void { | |||
| this.instances.forEach((instance, selector) => { | |||
| instance.destroy(); | |||
| }); | |||
| this.instances.clear(); | |||
| } | |||
| } | |||
| // Create singleton manager | |||
| const dataTableManager = new DataTableManager(); | |||
| // Initialize DataTable | |||
| const initializeDataTable = (): void => { | |||
| // Only initialize if the table exists | |||
| if (document.querySelector('#dataTable')) { | |||
| dataTableManager.initialize('#dataTable', { | |||
| sortable: true, | |||
| searchable: true, | |||
| pagination: true, | |||
| pageSize: 10, | |||
| responsive: true, | |||
| striped: true, | |||
| bordered: true, | |||
| hover: true, | |||
| }); | |||
| } | |||
| }; | |||
| // Initialize on load | |||
| if (document.readyState === 'loading') { | |||
| document.addEventListener('DOMContentLoaded', initializeDataTable); | |||
| } else { | |||
| initializeDataTable(); | |||
| } | |||
| // Reinitialize on theme change | |||
| window.addEventListener('adminator:themeChanged', () => { | |||
| setTimeout(initializeDataTable, 100); | |||
| }); | |||
| // Cleanup on page unload | |||
| window.addEventListener('beforeunload', () => { | |||
| dataTableManager.destroyAll(); | |||
| }); | |||
| // Export default for compatibility | |||
| export default { | |||
| init: initializeDataTable, | |||
| manager: dataTableManager, | |||
| VanillaDataTable, | |||
| DataTableManager, | |||
| }; | |||
| @ -0,0 +1,699 @@ | |||
| /** | |||
| * Enhanced HTML5 DatePicker with TypeScript | |||
| * Modern date picker implementation using native HTML5 input[type="date"] | |||
| */ | |||
| import DateUtils from '../utils/date'; | |||
| import type { ComponentInterface } from '../../types'; | |||
| // Type definitions for DatePicker | |||
| export interface DatePickerOptions { | |||
| format?: string; | |||
| autoclose?: boolean; | |||
| todayHighlight?: boolean; | |||
| minDate?: string; | |||
| maxDate?: string; | |||
| startDate?: string; | |||
| endDate?: string; | |||
| daysOfWeekDisabled?: number[]; | |||
| datesDisabled?: string[]; | |||
| weekStart?: number; | |||
| language?: string; | |||
| } | |||
| export interface DatePickerEvent { | |||
| date: string; | |||
| formattedDate: string; | |||
| dateObject: Date; | |||
| isValid: boolean; | |||
| } | |||
| export interface DatePickerValidation { | |||
| isValid: boolean; | |||
| errors: string[]; | |||
| } | |||
| declare global { | |||
| interface HTMLInputElement { | |||
| vanillaDatePicker?: VanillaDatePicker; | |||
| showPicker?: () => void; | |||
| } | |||
| } | |||
| // Enhanced HTML5 date picker with vanilla JS | |||
| export class VanillaDatePicker implements ComponentInterface { | |||
| public name: string = 'VanillaDatePicker'; | |||
| public element: HTMLInputElement; | |||
| public options: DatePickerOptions; | |||
| public isInitialized: boolean = false; | |||
| private wrapper: HTMLElement | null = null; | |||
| private todayIndicator: HTMLElement | null = null; | |||
| private validationErrors: string[] = []; | |||
| constructor(element: HTMLInputElement, options: DatePickerOptions = {}) { | |||
| this.element = element; | |||
| this.options = { | |||
| format: 'yyyy-mm-dd', | |||
| autoclose: true, | |||
| todayHighlight: true, | |||
| weekStart: 0, | |||
| language: 'en', | |||
| ...options, | |||
| }; | |||
| this.init(); | |||
| } | |||
| public init(): void { | |||
| this.convertToHTML5(); | |||
| this.enhanceInput(); | |||
| this.applyStyles(); | |||
| this.bindEvents(); | |||
| this.validateConstraints(); | |||
| this.addTodayHighlight(); | |||
| this.isInitialized = true; | |||
| } | |||
| public destroy(): void { | |||
| if (this.wrapper && this.wrapper.parentNode) { | |||
| this.wrapper.parentNode.replaceChild(this.element, this.wrapper); | |||
| } | |||
| this.isInitialized = false; | |||
| } | |||
| private convertToHTML5(): void { | |||
| // Convert input to HTML5 date type | |||
| this.element.type = 'date'; | |||
| this.element.classList.add('form-control', 'vanilla-datepicker'); | |||
| // Remove placeholder since HTML5 date inputs don't need it | |||
| this.element.removeAttribute('placeholder'); | |||
| // Set constraints | |||
| if (this.options.minDate) { | |||
| this.element.min = this.options.minDate; | |||
| } | |||
| if (this.options.maxDate) { | |||
| this.element.max = this.options.maxDate; | |||
| } | |||
| // Set default value if no value is set | |||
| if (!this.element.value) { | |||
| if (this.options.startDate) { | |||
| this.element.value = this.options.startDate; | |||
| } else if (this.options.todayHighlight) { | |||
| this.element.value = DateUtils.formatters.inputDate(DateUtils.now()); | |||
| } | |||
| } | |||
| // Ensure proper styling | |||
| this.element.style.minHeight = '38px'; | |||
| this.element.style.lineHeight = '1.5'; | |||
| this.element.style.cursor = 'pointer'; | |||
| // Add ARIA attributes | |||
| this.element.setAttribute('aria-label', 'Select date'); | |||
| this.element.setAttribute('role', 'textbox'); | |||
| this.element.setAttribute('aria-expanded', 'false'); | |||
| } | |||
| private enhanceInput(): void { | |||
| // Create wrapper for enhanced functionality | |||
| const wrapper = document.createElement('div'); | |||
| wrapper.className = 'vanilla-datepicker-wrapper'; | |||
| wrapper.style.position = 'relative'; | |||
| // Wrap the input | |||
| const parent = this.element.parentNode; | |||
| if (parent) { | |||
| parent.insertBefore(wrapper, this.element); | |||
| wrapper.appendChild(this.element); | |||
| } | |||
| // Add calendar icon if input is in an input group | |||
| const inputGroup = this.element.closest('.input-group'); | |||
| if (inputGroup) { | |||
| const calendarIcon = inputGroup.querySelector<HTMLElement>('.input-group-text i.ti-calendar'); | |||
| if (calendarIcon) { | |||
| calendarIcon.addEventListener('click', this.handleIconClick.bind(this)); | |||
| calendarIcon.style.cursor = 'pointer'; | |||
| calendarIcon.setAttribute('tabindex', '0'); | |||
| calendarIcon.setAttribute('role', 'button'); | |||
| calendarIcon.setAttribute('aria-label', 'Open calendar'); | |||
| } | |||
| } | |||
| this.wrapper = wrapper; | |||
| } | |||
| private applyStyles(): void { | |||
| const styleId = 'vanilla-datepicker-styles'; | |||
| if (document.getElementById(styleId)) return; | |||
| const style = document.createElement('style'); | |||
| style.id = styleId; | |||
| style.textContent = ` | |||
| .vanilla-datepicker-wrapper { | |||
| position: relative; | |||
| display: inline-block; | |||
| width: 100%; | |||
| } | |||
| .vanilla-datepicker { | |||
| width: 100%; | |||
| padding: 8px 12px; | |||
| border: 1px solid var(--c-border, #ced4da); | |||
| border-radius: 4px; | |||
| background-color: var(--c-bkg-card, #fff); | |||
| color: var(--c-text-base, #495057); | |||
| font-size: 14px; | |||
| font-family: inherit; | |||
| transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; | |||
| } | |||
| .vanilla-datepicker:focus { | |||
| outline: none; | |||
| border-color: var(--c-primary, #007bff); | |||
| box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); | |||
| } | |||
| .vanilla-datepicker:invalid { | |||
| border-color: var(--c-danger, #dc3545); | |||
| } | |||
| .vanilla-datepicker:invalid:focus { | |||
| border-color: var(--c-danger, #dc3545); | |||
| box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); | |||
| } | |||
| .vanilla-datepicker::-webkit-calendar-picker-indicator { | |||
| cursor: pointer; | |||
| border-radius: 4px; | |||
| margin-right: 2px; | |||
| opacity: 0.6; | |||
| transition: opacity 0.15s ease-in-out; | |||
| filter: var(--datepicker-icon-filter, none); | |||
| } | |||
| .vanilla-datepicker::-webkit-calendar-picker-indicator:hover { | |||
| opacity: 1; | |||
| } | |||
| .vanilla-datepicker::-webkit-datetime-edit-fields-wrapper { | |||
| padding: 0; | |||
| } | |||
| .vanilla-datepicker::-webkit-datetime-edit-month-field, | |||
| .vanilla-datepicker::-webkit-datetime-edit-day-field, | |||
| .vanilla-datepicker::-webkit-datetime-edit-year-field { | |||
| color: var(--c-text-base, #495057); | |||
| } | |||
| .vanilla-datepicker::-webkit-datetime-edit-text { | |||
| color: var(--c-text-muted, #6c757d); | |||
| } | |||
| /* Dark mode support */ | |||
| [data-theme="dark"] .vanilla-datepicker { | |||
| background-color: var(--c-bkg-card, #1f2937); | |||
| color: var(--c-text-base, #f9fafb); | |||
| border-color: var(--c-border, #374151); | |||
| --datepicker-icon-filter: invert(1); | |||
| } | |||
| .datepicker-today-indicator { | |||
| position: absolute; | |||
| top: 4px; | |||
| right: 12px; | |||
| width: 6px; | |||
| height: 6px; | |||
| background-color: var(--c-primary, #007bff); | |||
| border-radius: 50%; | |||
| opacity: 0.8; | |||
| pointer-events: none; | |||
| z-index: 1; | |||
| } | |||
| .datepicker-animation { | |||
| animation: datepicker-highlight 0.3s ease-in-out; | |||
| } | |||
| @keyframes datepicker-highlight { | |||
| 0% { transform: scale(1); } | |||
| 50% { transform: scale(1.02); } | |||
| 100% { transform: scale(1); } | |||
| } | |||
| .datepicker-error { | |||
| border-color: var(--c-danger, #dc3545) !important; | |||
| } | |||
| .datepicker-error:focus { | |||
| border-color: var(--c-danger, #dc3545) !important; | |||
| box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25) !important; | |||
| } | |||
| .datepicker-validation-feedback { | |||
| display: block; | |||
| width: 100%; | |||
| margin-top: 0.25rem; | |||
| font-size: 0.875rem; | |||
| color: var(--c-danger, #dc3545); | |||
| } | |||
| /* Responsive design */ | |||
| @media (max-width: 768px) { | |||
| .vanilla-datepicker { | |||
| padding: 10px 12px; | |||
| font-size: 16px; /* Prevent zoom on iOS */ | |||
| } | |||
| .vanilla-datepicker::-webkit-calendar-picker-indicator { | |||
| width: 20px; | |||
| height: 20px; | |||
| } | |||
| } | |||
| `; | |||
| document.head.appendChild(style); | |||
| } | |||
| private bindEvents(): void { | |||
| // Handle click events | |||
| this.element.addEventListener('click', this.handleClick.bind(this)); | |||
| // Handle keyboard events | |||
| this.element.addEventListener('keydown', this.handleKeydown.bind(this)); | |||
| // Handle change events | |||
| this.element.addEventListener('change', this.handleChange.bind(this)); | |||
| // Handle focus events | |||
| this.element.addEventListener('focus', this.handleFocus.bind(this)); | |||
| // Handle blur events | |||
| this.element.addEventListener('blur', this.handleBlur.bind(this)); | |||
| // Handle input events for real-time validation | |||
| this.element.addEventListener('input', this.handleInput.bind(this)); | |||
| } | |||
| private handleClick(): void { | |||
| this.openPicker(); | |||
| } | |||
| private handleKeydown(e: KeyboardEvent): void { | |||
| if (e.key === 'Enter' || e.key === ' ') { | |||
| e.preventDefault(); | |||
| this.openPicker(); | |||
| } | |||
| } | |||
| private handleChange(e: Event): void { | |||
| const target = e.target as HTMLInputElement; | |||
| this.handleDateChange(target.value); | |||
| } | |||
| private handleFocus(): void { | |||
| this.element.classList.add('datepicker-animation'); | |||
| this.element.setAttribute('aria-expanded', 'true'); | |||
| setTimeout(() => { | |||
| this.element.classList.remove('datepicker-animation'); | |||
| }, 300); | |||
| } | |||
| private handleBlur(): void { | |||
| this.element.setAttribute('aria-expanded', 'false'); | |||
| this.validateInput(); | |||
| } | |||
| private handleInput(): void { | |||
| this.validateInput(); | |||
| } | |||
| private handleIconClick(e: Event): void { | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| this.openPicker(); | |||
| } | |||
| private openPicker(): void { | |||
| this.element.focus(); | |||
| // Try to open the native date picker | |||
| if (this.element.showPicker && typeof this.element.showPicker === 'function') { | |||
| try { | |||
| this.element.showPicker(); | |||
| } catch (error) { | |||
| console.warn('DatePicker: showPicker not supported', error); | |||
| } | |||
| } | |||
| } | |||
| private handleDateChange(selectedDate: string): void { | |||
| if (selectedDate) { | |||
| // Add visual feedback | |||
| this.element.classList.add('datepicker-animation'); | |||
| setTimeout(() => { | |||
| this.element.classList.remove('datepicker-animation'); | |||
| }, 300); | |||
| // Validate the date | |||
| const validation = this.validateDate(selectedDate); | |||
| // Create event data | |||
| const eventData: DatePickerEvent = { | |||
| date: selectedDate, | |||
| formattedDate: this.formatDate(selectedDate), | |||
| dateObject: new Date(selectedDate), | |||
| isValid: validation.isValid, | |||
| }; | |||
| // Trigger custom event | |||
| const changeEvent = new CustomEvent('datepicker:change', { | |||
| detail: eventData, | |||
| bubbles: true, | |||
| }); | |||
| this.element.dispatchEvent(changeEvent); | |||
| // Update validation state | |||
| this.updateValidationState(validation); | |||
| } | |||
| } | |||
| private validateConstraints(): void { | |||
| // Set up date constraints based on options | |||
| if (this.options.datesDisabled && this.options.datesDisabled.length > 0) { | |||
| this.element.addEventListener('input', (e) => { | |||
| const target = e.target as HTMLInputElement; | |||
| if (this.options.datesDisabled!.includes(target.value)) { | |||
| this.addValidationError('This date is disabled'); | |||
| } | |||
| }); | |||
| } | |||
| if (this.options.daysOfWeekDisabled && this.options.daysOfWeekDisabled.length > 0) { | |||
| this.element.addEventListener('input', (e) => { | |||
| const target = e.target as HTMLInputElement; | |||
| const date = new Date(target.value); | |||
| const dayOfWeek = date.getDay(); | |||
| if (this.options.daysOfWeekDisabled!.includes(dayOfWeek)) { | |||
| this.addValidationError('This day of the week is disabled'); | |||
| } | |||
| }); | |||
| } | |||
| } | |||
| private validateDate(dateString: string): DatePickerValidation { | |||
| const errors: string[] = []; | |||
| const date = new Date(dateString); | |||
| // Check if date is valid | |||
| if (isNaN(date.getTime())) { | |||
| errors.push('Invalid date format'); | |||
| } | |||
| // Check min/max constraints | |||
| if (this.options.minDate) { | |||
| const minDate = new Date(this.options.minDate); | |||
| if (date < minDate) { | |||
| errors.push(`Date must be after ${this.formatDate(this.options.minDate)}`); | |||
| } | |||
| } | |||
| if (this.options.maxDate) { | |||
| const maxDate = new Date(this.options.maxDate); | |||
| if (date > maxDate) { | |||
| errors.push(`Date must be before ${this.formatDate(this.options.maxDate)}`); | |||
| } | |||
| } | |||
| // Check disabled dates | |||
| if (this.options.datesDisabled && this.options.datesDisabled.includes(dateString)) { | |||
| errors.push('This date is disabled'); | |||
| } | |||
| // Check disabled days of week | |||
| if (this.options.daysOfWeekDisabled && this.options.daysOfWeekDisabled.includes(date.getDay())) { | |||
| errors.push('This day of the week is disabled'); | |||
| } | |||
| return { | |||
| isValid: errors.length === 0, | |||
| errors, | |||
| }; | |||
| } | |||
| private validateInput(): void { | |||
| const value = this.element.value; | |||
| if (value) { | |||
| const validation = this.validateDate(value); | |||
| this.updateValidationState(validation); | |||
| } else { | |||
| this.clearValidationState(); | |||
| } | |||
| } | |||
| private updateValidationState(validation: DatePickerValidation): void { | |||
| this.validationErrors = validation.errors; | |||
| // Remove existing validation feedback | |||
| this.clearValidationFeedback(); | |||
| if (!validation.isValid) { | |||
| // Add error class | |||
| this.element.classList.add('datepicker-error'); | |||
| // Add validation feedback | |||
| const feedback = document.createElement('div'); | |||
| feedback.className = 'datepicker-validation-feedback'; | |||
| feedback.textContent = validation.errors.join(', '); | |||
| if (this.wrapper) { | |||
| this.wrapper.appendChild(feedback); | |||
| } | |||
| // Set ARIA attributes | |||
| this.element.setAttribute('aria-invalid', 'true'); | |||
| this.element.setAttribute('aria-describedby', 'datepicker-error'); | |||
| feedback.id = 'datepicker-error'; | |||
| } else { | |||
| this.clearValidationState(); | |||
| } | |||
| } | |||
| private clearValidationState(): void { | |||
| this.element.classList.remove('datepicker-error'); | |||
| this.element.setAttribute('aria-invalid', 'false'); | |||
| this.element.removeAttribute('aria-describedby'); | |||
| this.validationErrors = []; | |||
| this.clearValidationFeedback(); | |||
| } | |||
| private clearValidationFeedback(): void { | |||
| if (this.wrapper) { | |||
| const existingFeedback = this.wrapper.querySelector('.datepicker-validation-feedback'); | |||
| if (existingFeedback) { | |||
| existingFeedback.remove(); | |||
| } | |||
| } | |||
| } | |||
| private addValidationError(error: string): void { | |||
| if (!this.validationErrors.includes(error)) { | |||
| this.validationErrors.push(error); | |||
| } | |||
| } | |||
| private addTodayHighlight(): void { | |||
| if (this.options.todayHighlight) { | |||
| const today = DateUtils.formatters.inputDate(DateUtils.now()); | |||
| if (this.element.value === today) { | |||
| this.todayIndicator = document.createElement('div'); | |||
| this.todayIndicator.className = 'datepicker-today-indicator'; | |||
| this.todayIndicator.title = 'Today'; | |||
| if (this.wrapper) { | |||
| this.wrapper.appendChild(this.todayIndicator); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| private formatDate(dateString: string): string { | |||
| try { | |||
| const date = new Date(dateString); | |||
| return DateUtils.format(date, this.options.format || 'yyyy-mm-dd'); | |||
| } catch (error) { | |||
| return dateString; | |||
| } | |||
| } | |||
| // Public API methods | |||
| public setDate(dateString: string): void { | |||
| this.element.value = dateString; | |||
| this.handleDateChange(dateString); | |||
| } | |||
| public getDate(): string { | |||
| return this.element.value; | |||
| } | |||
| public getFormattedDate(): string { | |||
| return this.formatDate(this.element.value); | |||
| } | |||
| public getDateObject(): Date | null { | |||
| return this.element.value ? new Date(this.element.value) : null; | |||
| } | |||
| public isValid(): boolean { | |||
| return this.validationErrors.length === 0; | |||
| } | |||
| public getValidationErrors(): string[] { | |||
| return [...this.validationErrors]; | |||
| } | |||
| public setMinDate(dateString: string): void { | |||
| this.options.minDate = dateString; | |||
| this.element.min = dateString; | |||
| this.validateInput(); | |||
| } | |||
| public setMaxDate(dateString: string): void { | |||
| this.options.maxDate = dateString; | |||
| this.element.max = dateString; | |||
| this.validateInput(); | |||
| } | |||
| public reset(): void { | |||
| this.element.value = ''; | |||
| this.clearValidationState(); | |||
| if (this.todayIndicator) { | |||
| this.todayIndicator.remove(); | |||
| this.todayIndicator = null; | |||
| } | |||
| } | |||
| public enable(): void { | |||
| this.element.disabled = false; | |||
| } | |||
| public disable(): void { | |||
| this.element.disabled = true; | |||
| } | |||
| public updateOptions(newOptions: Partial<DatePickerOptions>): void { | |||
| this.options = { ...this.options, ...newOptions }; | |||
| this.validateConstraints(); | |||
| this.validateInput(); | |||
| } | |||
| } | |||
| // DatePicker Manager | |||
| export class DatePickerManager { | |||
| private instances: Map<string, VanillaDatePicker> = new Map(); | |||
| public initialize(selector: string, options: DatePickerOptions = {}): VanillaDatePicker[] { | |||
| const elements = document.querySelectorAll<HTMLInputElement>(selector); | |||
| const instances: VanillaDatePicker[] = []; | |||
| elements.forEach((element, index) => { | |||
| // Clean up existing instance | |||
| if (element.vanillaDatePicker) { | |||
| element.vanillaDatePicker.destroy(); | |||
| } | |||
| // Create new instance | |||
| const datePicker = new VanillaDatePicker(element, options); | |||
| element.vanillaDatePicker = datePicker; | |||
| // Store in manager | |||
| const key = `${selector}-${index}`; | |||
| this.instances.set(key, datePicker); | |||
| instances.push(datePicker); | |||
| }); | |||
| return instances; | |||
| } | |||
| public getInstances(selector: string): VanillaDatePicker[] { | |||
| const instances: VanillaDatePicker[] = []; | |||
| this.instances.forEach((instance, key) => { | |||
| if (key.startsWith(selector)) { | |||
| instances.push(instance); | |||
| } | |||
| }); | |||
| return instances; | |||
| } | |||
| public destroyInstances(selector: string): void { | |||
| const keysToDelete: string[] = []; | |||
| this.instances.forEach((instance, key) => { | |||
| if (key.startsWith(selector)) { | |||
| instance.destroy(); | |||
| keysToDelete.push(key); | |||
| } | |||
| }); | |||
| keysToDelete.forEach(key => this.instances.delete(key)); | |||
| } | |||
| public destroyAll(): void { | |||
| this.instances.forEach(instance => instance.destroy()); | |||
| this.instances.clear(); | |||
| } | |||
| } | |||
| // Create singleton manager | |||
| const datePickerManager = new DatePickerManager(); | |||
| // Initialize date pickers | |||
| const initializeDatePickers = (): void => { | |||
| // Start date pickers | |||
| datePickerManager.initialize('.start-date', { | |||
| format: 'yyyy-mm-dd', | |||
| autoclose: true, | |||
| todayHighlight: true, | |||
| }); | |||
| // End date pickers | |||
| datePickerManager.initialize('.end-date', { | |||
| format: 'yyyy-mm-dd', | |||
| autoclose: true, | |||
| todayHighlight: true, | |||
| }); | |||
| // Generic date pickers | |||
| datePickerManager.initialize('input[type="date"]:not(.start-date):not(.end-date)', { | |||
| format: 'yyyy-mm-dd', | |||
| autoclose: true, | |||
| todayHighlight: true, | |||
| }); | |||
| }; | |||
| // Initialize on load | |||
| if (document.readyState === 'loading') { | |||
| document.addEventListener('DOMContentLoaded', initializeDatePickers); | |||
| } else { | |||
| initializeDatePickers(); | |||
| } | |||
| // Reinitialize on theme change | |||
| window.addEventListener('adminator:themeChanged', () => { | |||
| setTimeout(initializeDatePickers, 100); | |||
| }); | |||
| // Cleanup on page unload | |||
| window.addEventListener('beforeunload', () => { | |||
| datePickerManager.destroyAll(); | |||
| }); | |||
| // Export default for compatibility | |||
| export default { | |||
| init: initializeDatePickers, | |||
| manager: datePickerManager, | |||
| VanillaDatePicker, | |||
| DatePickerManager, | |||
| }; | |||
| @ -0,0 +1,740 @@ | |||
| /** | |||
| * UI Bootstrap Components with TypeScript | |||
| * Vanilla JavaScript implementations for Bootstrap components | |||
| */ | |||
| import type { ComponentInterface } from '../../types'; | |||
| // Type definitions for UI components | |||
| export interface UIComponentOptions { | |||
| autoInit?: boolean; | |||
| selector?: string; | |||
| } | |||
| export interface TooltipOptions { | |||
| placement?: 'top' | 'bottom' | 'left' | 'right'; | |||
| delay?: number; | |||
| animation?: boolean; | |||
| } | |||
| export interface PopoverOptions { | |||
| placement?: 'top' | 'bottom' | 'left' | 'right'; | |||
| trigger?: 'click' | 'hover' | 'focus' | 'manual'; | |||
| html?: boolean; | |||
| animation?: boolean; | |||
| } | |||
| export interface ModalOptions { | |||
| backdrop?: boolean | 'static'; | |||
| keyboard?: boolean; | |||
| focus?: boolean; | |||
| show?: boolean; | |||
| } | |||
| export interface AccordionOptions { | |||
| parent?: string; | |||
| toggle?: boolean; | |||
| } | |||
| export interface DropdownOptions { | |||
| offset?: [number, number]; | |||
| flip?: boolean; | |||
| boundary?: 'clippingParents' | 'viewport' | HTMLElement; | |||
| } | |||
| // Modal functionality | |||
| export class VanillaModal implements ComponentInterface { | |||
| public name: string = 'VanillaModal'; | |||
| public element: HTMLElement; | |||
| public options: ModalOptions; | |||
| public isInitialized: boolean = false; | |||
| private modal: HTMLElement | null = null; | |||
| private backdrop: HTMLElement | null = null; | |||
| private isOpen: boolean = false; | |||
| private escapeHandler: ((e: KeyboardEvent) => void) | null = null; | |||
| constructor(element: HTMLElement, options: ModalOptions = {}) { | |||
| this.element = element; | |||
| this.options = { | |||
| backdrop: true, | |||
| keyboard: true, | |||
| focus: true, | |||
| show: false, | |||
| ...options, | |||
| }; | |||
| this.init(); | |||
| } | |||
| public init(): void { | |||
| const targetSelector = this.element.getAttribute('data-bs-target'); | |||
| if (!targetSelector) { | |||
| console.warn('Modal: Missing data-bs-target attribute'); | |||
| return; | |||
| } | |||
| this.modal = document.querySelector(targetSelector); | |||
| if (!this.modal) { | |||
| console.warn(`Modal: Target element ${targetSelector} not found`); | |||
| return; | |||
| } | |||
| this.element.addEventListener('click', this.handleElementClick.bind(this)); | |||
| // Close button functionality | |||
| const closeButtons = this.modal.querySelectorAll<HTMLElement>('[data-bs-dismiss="modal"]'); | |||
| closeButtons.forEach(btn => { | |||
| btn.addEventListener('click', this.hide.bind(this)); | |||
| }); | |||
| // Close on backdrop click | |||
| if (this.options.backdrop !== false) { | |||
| this.modal.addEventListener('click', this.handleBackdropClick.bind(this)); | |||
| } | |||
| this.isInitialized = true; | |||
| } | |||
| public destroy(): void { | |||
| if (this.escapeHandler) { | |||
| document.removeEventListener('keydown', this.escapeHandler); | |||
| this.escapeHandler = null; | |||
| } | |||
| this.hide(); | |||
| this.isInitialized = false; | |||
| } | |||
| private handleElementClick(e: Event): void { | |||
| e.preventDefault(); | |||
| this.show(); | |||
| } | |||
| private handleBackdropClick(e: Event): void { | |||
| if (e.target === this.modal && this.options.backdrop !== 'static') { | |||
| this.hide(); | |||
| } | |||
| } | |||
| public show(): void { | |||
| if (this.isOpen || !this.modal) return; | |||
| // Create backdrop | |||
| if (this.options.backdrop !== false) { | |||
| this.backdrop = document.createElement('div'); | |||
| this.backdrop.className = 'modal-backdrop fade show'; | |||
| document.body.appendChild(this.backdrop); | |||
| } | |||
| // Show modal | |||
| this.modal.style.display = 'block'; | |||
| this.modal.classList.add('show'); | |||
| document.body.classList.add('modal-open'); | |||
| this.isOpen = true; | |||
| // Focus the modal | |||
| if (this.options.focus) { | |||
| this.modal.setAttribute('tabindex', '-1'); | |||
| this.modal.focus(); | |||
| } | |||
| // Escape key handler | |||
| if (this.options.keyboard) { | |||
| this.escapeHandler = this.handleEscapeKey.bind(this); | |||
| document.addEventListener('keydown', this.escapeHandler); | |||
| } | |||
| } | |||
| public hide(): void { | |||
| if (!this.isOpen || !this.modal) return; | |||
| // Hide modal | |||
| this.modal.classList.remove('show'); | |||
| this.modal.style.display = 'none'; | |||
| document.body.classList.remove('modal-open'); | |||
| // Remove backdrop | |||
| if (this.backdrop) { | |||
| this.backdrop.remove(); | |||
| this.backdrop = null; | |||
| } | |||
| this.isOpen = false; | |||
| // Remove escape handler | |||
| if (this.escapeHandler) { | |||
| document.removeEventListener('keydown', this.escapeHandler); | |||
| this.escapeHandler = null; | |||
| } | |||
| } | |||
| private handleEscapeKey(e: KeyboardEvent): void { | |||
| if (e.key === 'Escape') { | |||
| this.hide(); | |||
| } | |||
| } | |||
| public toggle(): void { | |||
| if (this.isOpen) { | |||
| this.hide(); | |||
| } else { | |||
| this.show(); | |||
| } | |||
| } | |||
| public isVisible(): boolean { | |||
| return this.isOpen; | |||
| } | |||
| } | |||
| // Dropdown functionality | |||
| export class VanillaDropdown implements ComponentInterface { | |||
| public name: string = 'VanillaDropdown'; | |||
| public element: HTMLElement; | |||
| public options: DropdownOptions; | |||
| public isInitialized: boolean = false; | |||
| private menu: HTMLElement | null = null; | |||
| private isOpen: boolean = false; | |||
| private outsideClickHandler: ((e: Event) => void) | null = null; | |||
| private escapeHandler: ((e: KeyboardEvent) => void) | null = null; | |||
| constructor(element: HTMLElement, options: DropdownOptions = {}) { | |||
| this.element = element; | |||
| this.options = { | |||
| offset: [0, 2], | |||
| flip: true, | |||
| boundary: 'clippingParents', | |||
| ...options, | |||
| }; | |||
| this.init(); | |||
| } | |||
| public init(): void { | |||
| const parent = this.element.parentNode as HTMLElement; | |||
| if (!parent) return; | |||
| this.menu = parent.querySelector('.dropdown-menu'); | |||
| if (!this.menu) return; | |||
| this.element.addEventListener('click', this.handleElementClick.bind(this)); | |||
| // Setup event handlers | |||
| this.outsideClickHandler = this.handleOutsideClick.bind(this); | |||
| this.escapeHandler = this.handleEscapeKey.bind(this); | |||
| this.isInitialized = true; | |||
| } | |||
| public destroy(): void { | |||
| this.hide(); | |||
| if (this.outsideClickHandler) { | |||
| document.removeEventListener('click', this.outsideClickHandler); | |||
| this.outsideClickHandler = null; | |||
| } | |||
| if (this.escapeHandler) { | |||
| document.removeEventListener('keydown', this.escapeHandler); | |||
| this.escapeHandler = null; | |||
| } | |||
| this.isInitialized = false; | |||
| } | |||
| private handleElementClick(e: Event): void { | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| this.toggle(); | |||
| } | |||
| private handleOutsideClick(e: Event): void { | |||
| const parent = this.element.parentNode as HTMLElement; | |||
| if (parent && !parent.contains(e.target as Node)) { | |||
| this.hide(); | |||
| } | |||
| } | |||
| private handleEscapeKey(e: KeyboardEvent): void { | |||
| if (e.key === 'Escape' && this.isOpen) { | |||
| this.hide(); | |||
| } | |||
| } | |||
| public toggle(): void { | |||
| if (this.isOpen) { | |||
| this.hide(); | |||
| } else { | |||
| this.show(); | |||
| } | |||
| } | |||
| public show(): void { | |||
| if (this.isOpen || !this.menu) return; | |||
| // Close other dropdowns | |||
| document.querySelectorAll<HTMLElement>('.dropdown-menu.show').forEach(menu => { | |||
| menu.classList.remove('show'); | |||
| }); | |||
| this.menu.classList.add('show'); | |||
| this.element.setAttribute('aria-expanded', 'true'); | |||
| this.isOpen = true; | |||
| // Add event listeners | |||
| if (this.outsideClickHandler) { | |||
| document.addEventListener('click', this.outsideClickHandler); | |||
| } | |||
| if (this.escapeHandler) { | |||
| document.addEventListener('keydown', this.escapeHandler); | |||
| } | |||
| } | |||
| public hide(): void { | |||
| if (!this.isOpen || !this.menu) return; | |||
| this.menu.classList.remove('show'); | |||
| this.element.setAttribute('aria-expanded', 'false'); | |||
| this.isOpen = false; | |||
| // Remove event listeners | |||
| if (this.outsideClickHandler) { | |||
| document.removeEventListener('click', this.outsideClickHandler); | |||
| } | |||
| if (this.escapeHandler) { | |||
| document.removeEventListener('keydown', this.escapeHandler); | |||
| } | |||
| } | |||
| } | |||
| // Popover functionality | |||
| export class VanillaPopover implements ComponentInterface { | |||
| public name: string = 'VanillaPopover'; | |||
| public element: HTMLElement; | |||
| public options: PopoverOptions; | |||
| public isInitialized: boolean = false; | |||
| private popover: HTMLElement | null = null; | |||
| private isOpen: boolean = false; | |||
| private outsideClickHandler: ((e: Event) => void) | null = null; | |||
| constructor(element: HTMLElement, options: PopoverOptions = {}) { | |||
| this.element = element; | |||
| this.options = { | |||
| placement: 'top', | |||
| trigger: 'click', | |||
| html: false, | |||
| animation: true, | |||
| ...options, | |||
| }; | |||
| this.init(); | |||
| } | |||
| public init(): void { | |||
| if (this.options.trigger === 'click') { | |||
| this.element.addEventListener('click', this.handleElementClick.bind(this)); | |||
| } else if (this.options.trigger === 'hover') { | |||
| this.element.addEventListener('mouseenter', this.show.bind(this)); | |||
| this.element.addEventListener('mouseleave', this.hide.bind(this)); | |||
| } else if (this.options.trigger === 'focus') { | |||
| this.element.addEventListener('focus', this.show.bind(this)); | |||
| this.element.addEventListener('blur', this.hide.bind(this)); | |||
| } | |||
| this.outsideClickHandler = this.handleOutsideClick.bind(this); | |||
| this.isInitialized = true; | |||
| } | |||
| public destroy(): void { | |||
| this.hide(); | |||
| if (this.outsideClickHandler) { | |||
| document.removeEventListener('click', this.outsideClickHandler); | |||
| this.outsideClickHandler = null; | |||
| } | |||
| this.isInitialized = false; | |||
| } | |||
| private handleElementClick(e: Event): void { | |||
| e.preventDefault(); | |||
| this.toggle(); | |||
| } | |||
| private handleOutsideClick(e: Event): void { | |||
| if (!this.element.contains(e.target as Node) && | |||
| (!this.popover || !this.popover.contains(e.target as Node))) { | |||
| this.hide(); | |||
| } | |||
| } | |||
| public toggle(): void { | |||
| if (this.isOpen) { | |||
| this.hide(); | |||
| } else { | |||
| this.show(); | |||
| } | |||
| } | |||
| public show(): void { | |||
| if (this.isOpen) return; | |||
| // Close other popovers | |||
| document.querySelectorAll<HTMLElement>('.popover').forEach(popover => { | |||
| popover.remove(); | |||
| }); | |||
| const title = this.element.getAttribute('title') || this.element.getAttribute('data-bs-title'); | |||
| const content = this.element.getAttribute('data-bs-content'); | |||
| if (!content) return; | |||
| this.popover = document.createElement('div'); | |||
| this.popover.className = `popover bs-popover-${this.options.placement} show`; | |||
| this.popover.style.position = 'absolute'; | |||
| this.popover.style.zIndex = '1070'; | |||
| this.popover.style.maxWidth = '276px'; | |||
| this.popover.style.backgroundColor = '#fff'; | |||
| this.popover.style.border = '1px solid rgba(0,0,0,.2)'; | |||
| this.popover.style.borderRadius = '6px'; | |||
| this.popover.style.boxShadow = '0 5px 10px rgba(0,0,0,.2)'; | |||
| let popoverContent = ''; | |||
| if (title) { | |||
| popoverContent += `<div class="popover-header" style="padding: 8px 14px; margin-bottom: 0; font-size: 14px; background-color: #f7f7f7; border-bottom: 1px solid #ebebeb; border-radius: 5px 5px 0 0; font-weight: 600;">${title}</div>`; | |||
| } | |||
| popoverContent += `<div class="popover-body" style="padding: 9px 14px; word-wrap: break-word;">${content}</div>`; | |||
| this.popover.innerHTML = popoverContent; | |||
| document.body.appendChild(this.popover); | |||
| // Position popover | |||
| this.positionPopover(); | |||
| this.isOpen = true; | |||
| // Add outside click handler | |||
| if (this.outsideClickHandler) { | |||
| document.addEventListener('click', this.outsideClickHandler); | |||
| } | |||
| } | |||
| public hide(): void { | |||
| if (!this.isOpen) return; | |||
| if (this.popover) { | |||
| this.popover.remove(); | |||
| this.popover = null; | |||
| } | |||
| this.isOpen = false; | |||
| // Remove outside click handler | |||
| if (this.outsideClickHandler) { | |||
| document.removeEventListener('click', this.outsideClickHandler); | |||
| } | |||
| } | |||
| private positionPopover(): void { | |||
| if (!this.popover) return; | |||
| const rect = this.element.getBoundingClientRect(); | |||
| const popoverRect = this.popover.getBoundingClientRect(); | |||
| switch (this.options.placement) { | |||
| case 'top': | |||
| this.popover.style.left = `${rect.left + (rect.width / 2) - (popoverRect.width / 2)}px`; | |||
| this.popover.style.top = `${rect.top - popoverRect.height - 10}px`; | |||
| break; | |||
| case 'bottom': | |||
| this.popover.style.left = `${rect.left + (rect.width / 2) - (popoverRect.width / 2)}px`; | |||
| this.popover.style.top = `${rect.bottom + 10}px`; | |||
| break; | |||
| case 'left': | |||
| this.popover.style.left = `${rect.left - popoverRect.width - 10}px`; | |||
| this.popover.style.top = `${rect.top + (rect.height / 2) - (popoverRect.height / 2)}px`; | |||
| break; | |||
| case 'right': | |||
| this.popover.style.left = `${rect.right + 10}px`; | |||
| this.popover.style.top = `${rect.top + (rect.height / 2) - (popoverRect.height / 2)}px`; | |||
| break; | |||
| } | |||
| } | |||
| } | |||
| // Tooltip functionality | |||
| export class VanillaTooltip implements ComponentInterface { | |||
| public name: string = 'VanillaTooltip'; | |||
| public element: HTMLElement; | |||
| public options: TooltipOptions; | |||
| public isInitialized: boolean = false; | |||
| private tooltip: HTMLElement | null = null; | |||
| private showTimeout: number | null = null; | |||
| private hideTimeout: number | null = null; | |||
| constructor(element: HTMLElement, options: TooltipOptions = {}) { | |||
| this.element = element; | |||
| this.options = { | |||
| placement: 'top', | |||
| delay: 0, | |||
| animation: true, | |||
| ...options, | |||
| }; | |||
| this.init(); | |||
| } | |||
| public init(): void { | |||
| this.element.addEventListener('mouseenter', this.handleMouseEnter.bind(this)); | |||
| this.element.addEventListener('mouseleave', this.handleMouseLeave.bind(this)); | |||
| this.element.addEventListener('focus', this.handleFocus.bind(this)); | |||
| this.element.addEventListener('blur', this.handleBlur.bind(this)); | |||
| this.isInitialized = true; | |||
| } | |||
| public destroy(): void { | |||
| this.hide(); | |||
| this.clearTimeouts(); | |||
| this.isInitialized = false; | |||
| } | |||
| private handleMouseEnter(): void { | |||
| this.clearTimeouts(); | |||
| this.showTimeout = window.setTimeout(() => this.show(), this.options.delay); | |||
| } | |||
| private handleMouseLeave(): void { | |||
| this.clearTimeouts(); | |||
| this.hideTimeout = window.setTimeout(() => this.hide(), this.options.delay); | |||
| } | |||
| private handleFocus(): void { | |||
| this.show(); | |||
| } | |||
| private handleBlur(): void { | |||
| this.hide(); | |||
| } | |||
| private clearTimeouts(): void { | |||
| if (this.showTimeout) { | |||
| clearTimeout(this.showTimeout); | |||
| this.showTimeout = null; | |||
| } | |||
| if (this.hideTimeout) { | |||
| clearTimeout(this.hideTimeout); | |||
| this.hideTimeout = null; | |||
| } | |||
| } | |||
| public show(): void { | |||
| if (this.tooltip) return; | |||
| const title = this.element.getAttribute('title') || this.element.getAttribute('data-bs-title'); | |||
| if (!title) return; | |||
| this.tooltip = document.createElement('div'); | |||
| this.tooltip.className = `tooltip bs-tooltip-${this.options.placement} show`; | |||
| this.tooltip.style.position = 'absolute'; | |||
| this.tooltip.style.zIndex = '1070'; | |||
| this.tooltip.style.maxWidth = '200px'; | |||
| this.tooltip.style.padding = '4px 8px'; | |||
| this.tooltip.style.fontSize = '12px'; | |||
| this.tooltip.style.backgroundColor = '#000'; | |||
| this.tooltip.style.color = '#fff'; | |||
| this.tooltip.style.borderRadius = '4px'; | |||
| this.tooltip.style.pointerEvents = 'none'; | |||
| this.tooltip.style.whiteSpace = 'nowrap'; | |||
| this.tooltip.innerHTML = `<div class="tooltip-inner">${title}</div>`; | |||
| document.body.appendChild(this.tooltip); | |||
| // Position tooltip | |||
| this.positionTooltip(); | |||
| } | |||
| public hide(): void { | |||
| if (this.tooltip) { | |||
| this.tooltip.remove(); | |||
| this.tooltip = null; | |||
| } | |||
| } | |||
| private positionTooltip(): void { | |||
| if (!this.tooltip) return; | |||
| const rect = this.element.getBoundingClientRect(); | |||
| const tooltipRect = this.tooltip.getBoundingClientRect(); | |||
| switch (this.options.placement) { | |||
| case 'top': | |||
| this.tooltip.style.left = `${rect.left + (rect.width / 2) - (tooltipRect.width / 2)}px`; | |||
| this.tooltip.style.top = `${rect.top - tooltipRect.height - 5}px`; | |||
| break; | |||
| case 'bottom': | |||
| this.tooltip.style.left = `${rect.left + (rect.width / 2) - (tooltipRect.width / 2)}px`; | |||
| this.tooltip.style.top = `${rect.bottom + 5}px`; | |||
| break; | |||
| case 'left': | |||
| this.tooltip.style.left = `${rect.left - tooltipRect.width - 5}px`; | |||
| this.tooltip.style.top = `${rect.top + (rect.height / 2) - (tooltipRect.height / 2)}px`; | |||
| break; | |||
| case 'right': | |||
| this.tooltip.style.left = `${rect.right + 5}px`; | |||
| this.tooltip.style.top = `${rect.top + (rect.height / 2) - (tooltipRect.height / 2)}px`; | |||
| break; | |||
| } | |||
| } | |||
| } | |||
| // Accordion functionality | |||
| export class VanillaAccordion implements ComponentInterface { | |||
| public name: string = 'VanillaAccordion'; | |||
| public element: HTMLElement; | |||
| public options: AccordionOptions; | |||
| public isInitialized: boolean = false; | |||
| private accordion: HTMLElement | null = null; | |||
| private target: HTMLElement | null = null; | |||
| private isOpen: boolean = false; | |||
| constructor(element: HTMLElement, options: AccordionOptions = {}) { | |||
| this.element = element; | |||
| this.options = { | |||
| toggle: true, | |||
| ...options, | |||
| }; | |||
| this.init(); | |||
| } | |||
| public init(): void { | |||
| this.accordion = this.element.closest('.accordion'); | |||
| const targetSelector = this.element.getAttribute('data-bs-target'); | |||
| if (!targetSelector) return; | |||
| this.target = document.querySelector(targetSelector); | |||
| if (!this.target) return; | |||
| this.isOpen = !this.element.classList.contains('collapsed'); | |||
| this.element.addEventListener('click', this.handleElementClick.bind(this)); | |||
| this.isInitialized = true; | |||
| } | |||
| public destroy(): void { | |||
| this.isInitialized = false; | |||
| } | |||
| private handleElementClick(e: Event): void { | |||
| e.preventDefault(); | |||
| this.toggle(); | |||
| } | |||
| public toggle(): void { | |||
| if (this.isOpen) { | |||
| this.hide(); | |||
| } else { | |||
| this.show(); | |||
| } | |||
| } | |||
| public show(): void { | |||
| if (this.isOpen || !this.target) return; | |||
| // Close other accordion items in the same parent | |||
| if (this.accordion) { | |||
| const otherItems = this.accordion.querySelectorAll<HTMLElement>('.accordion-collapse.show'); | |||
| otherItems.forEach(item => { | |||
| if (item !== this.target) { | |||
| item.classList.remove('show'); | |||
| const button = this.accordion!.querySelector<HTMLElement>(`[data-bs-target="#${item.id}"]`); | |||
| if (button) { | |||
| button.classList.add('collapsed'); | |||
| button.setAttribute('aria-expanded', 'false'); | |||
| } | |||
| } | |||
| }); | |||
| } | |||
| // Show this item | |||
| this.target.classList.add('show'); | |||
| this.element.classList.remove('collapsed'); | |||
| this.element.setAttribute('aria-expanded', 'true'); | |||
| this.isOpen = true; | |||
| } | |||
| public hide(): void { | |||
| if (!this.isOpen || !this.target) return; | |||
| this.target.classList.remove('show'); | |||
| this.element.classList.add('collapsed'); | |||
| this.element.setAttribute('aria-expanded', 'false'); | |||
| this.isOpen = false; | |||
| } | |||
| } | |||
| // UI Manager Class | |||
| export class UIManager { | |||
| private components: Map<string, ComponentInterface> = new Map(); | |||
| public initializeComponents(): void { | |||
| // Initialize modals | |||
| document.querySelectorAll<HTMLElement>('[data-bs-toggle="modal"]').forEach(element => { | |||
| const modal = new VanillaModal(element); | |||
| this.components.set(`modal-${element.id || Date.now()}`, modal); | |||
| }); | |||
| // Initialize dropdowns | |||
| document.querySelectorAll<HTMLElement>('[data-bs-toggle="dropdown"]').forEach(element => { | |||
| const dropdown = new VanillaDropdown(element); | |||
| this.components.set(`dropdown-${element.id || Date.now()}`, dropdown); | |||
| }); | |||
| // Initialize popovers | |||
| document.querySelectorAll<HTMLElement>('[data-bs-toggle="popover"]').forEach(element => { | |||
| const popover = new VanillaPopover(element); | |||
| this.components.set(`popover-${element.id || Date.now()}`, popover); | |||
| }); | |||
| // Initialize tooltips | |||
| document.querySelectorAll<HTMLElement>('[data-bs-toggle="tooltip"]').forEach(element => { | |||
| const tooltip = new VanillaTooltip(element); | |||
| this.components.set(`tooltip-${element.id || Date.now()}`, tooltip); | |||
| }); | |||
| // Initialize accordions | |||
| document.querySelectorAll<HTMLElement>('[data-bs-toggle="collapse"]').forEach(element => { | |||
| const accordion = new VanillaAccordion(element); | |||
| this.components.set(`accordion-${element.id || Date.now()}`, accordion); | |||
| }); | |||
| } | |||
| public destroyComponents(): void { | |||
| this.components.forEach(component => { | |||
| component.destroy(); | |||
| }); | |||
| this.components.clear(); | |||
| } | |||
| public getComponent(id: string): ComponentInterface | undefined { | |||
| return this.components.get(id); | |||
| } | |||
| } | |||
| // Create and export singleton instance | |||
| const uiManager = new UIManager(); | |||
| // Initialize when DOM is ready | |||
| const initializeUI = (): void => { | |||
| uiManager.initializeComponents(); | |||
| }; | |||
| if (document.readyState === 'loading') { | |||
| document.addEventListener('DOMContentLoaded', initializeUI); | |||
| } else { | |||
| initializeUI(); | |||
| } | |||
| // Export default object for compatibility | |||
| export default { | |||
| init: initializeUI, | |||
| manager: uiManager, | |||
| Modal: VanillaModal, | |||
| Dropdown: VanillaDropdown, | |||
| Popover: VanillaPopover, | |||
| Tooltip: VanillaTooltip, | |||
| Accordion: VanillaAccordion, | |||
| }; | |||
| @ -0,0 +1,363 @@ | |||
| /** | |||
| * Modern Date Utilities with TypeScript | |||
| * Using Day.js (2KB) instead of Moment.js (67KB) - 97% size reduction | |||
| * Provides consistent date formatting and manipulation across the application | |||
| */ | |||
| import dayjs, { Dayjs, ConfigType, UnitType, ManipulateType } from 'dayjs'; | |||
| import utc from 'dayjs/plugin/utc'; | |||
| import timezone from 'dayjs/plugin/timezone'; | |||
| import relativeTime from 'dayjs/plugin/relativeTime'; | |||
| import customParseFormat from 'dayjs/plugin/customParseFormat'; | |||
| import advancedFormat from 'dayjs/plugin/advancedFormat'; | |||
| import isBetween from 'dayjs/plugin/isBetween'; | |||
| // Enable Day.js plugins | |||
| dayjs.extend(utc); | |||
| dayjs.extend(timezone); | |||
| dayjs.extend(relativeTime); | |||
| dayjs.extend(customParseFormat); | |||
| dayjs.extend(advancedFormat); | |||
| dayjs.extend(isBetween); | |||
| // Type definitions | |||
| export interface CalendarDay { | |||
| date: string; | |||
| day: number; | |||
| isCurrentMonth: boolean; | |||
| isToday: boolean; | |||
| dayjs: Dayjs; | |||
| } | |||
| export interface CalendarMonth { | |||
| month: string; | |||
| year: number; | |||
| monthIndex: number; | |||
| days: CalendarDay[]; | |||
| } | |||
| export interface WeekDay { | |||
| date: string; | |||
| day: number; | |||
| dayName: string; | |||
| shortDayName: string; | |||
| isToday: boolean; | |||
| dayjs: Dayjs; | |||
| } | |||
| export interface WeekData { | |||
| weekStart: string; | |||
| weekEnd: string; | |||
| days: WeekDay[]; | |||
| } | |||
| export interface ChartDatePoint { | |||
| date: string; | |||
| label: string; | |||
| value: string; | |||
| dayjs: Dayjs; | |||
| } | |||
| export type DateInput = ConfigType; | |||
| export type DateUnit = UnitType; | |||
| export type DateManipulateUnit = ManipulateType; | |||
| export interface DateFormatters { | |||
| shortDate: (date: DateInput) => string; | |||
| longDate: (date: DateInput) => string; | |||
| dateTime: (date: DateInput) => string; | |||
| calendarDate: (date: DateInput) => string; | |||
| calendarDateTime: (date: DateInput) => string; | |||
| inputDate: (date: DateInput) => string; | |||
| inputDateTime: (date: DateInput) => string; | |||
| timeOnly: (date: DateInput) => string; | |||
| monthYear: (date: DateInput) => string; | |||
| dayMonth: (date: DateInput) => string; | |||
| relative: (date: DateInput) => string; | |||
| relativeCalendar: (date: DateInput) => string; | |||
| } | |||
| export interface DateCalendarUtils { | |||
| getMonthData: (date?: DateInput) => CalendarMonth; | |||
| getWeekData: (date?: DateInput) => WeekData; | |||
| } | |||
| export interface DateFormUtils { | |||
| toInputValue: (date: DateInput) => string; | |||
| toDateTimeInputValue: (date: DateInput) => string; | |||
| fromInputValue: (value: string) => Dayjs; | |||
| validateDateInput: (value: string) => boolean; | |||
| } | |||
| export interface DateChartUtils { | |||
| generateDateRange: (start: DateInput, end: DateInput, interval?: DateManipulateUnit) => ChartDatePoint[]; | |||
| getChartLabels: (period?: 'week' | 'month' | 'year') => string[]; | |||
| } | |||
| export interface DateTimezoneUtils { | |||
| convert: (date: DateInput, tz: string) => Dayjs; | |||
| utc: (date: DateInput) => Dayjs; | |||
| local: (date: DateInput) => Dayjs; | |||
| guess: () => string; | |||
| } | |||
| export interface DateUtilsInterface { | |||
| now: () => Dayjs; | |||
| parse: (input: DateInput, format?: string) => Dayjs; | |||
| format: (date: DateInput, format?: string) => string; | |||
| formatters: DateFormatters; | |||
| add: (date: DateInput, amount: number, unit: DateManipulateUnit) => Dayjs; | |||
| subtract: (date: DateInput, amount: number, unit: DateManipulateUnit) => Dayjs; | |||
| startOf: (date: DateInput, unit: DateUnit) => Dayjs; | |||
| endOf: (date: DateInput, unit: DateUnit) => Dayjs; | |||
| isBefore: (date1: DateInput, date2: DateInput) => boolean; | |||
| isAfter: (date1: DateInput, date2: DateInput) => boolean; | |||
| isSame: (date1: DateInput, date2: DateInput, unit?: DateUnit) => boolean; | |||
| isBetween: (date: DateInput, start: DateInput, end: DateInput) => boolean; | |||
| isValid: (date: DateInput) => boolean; | |||
| timezone: DateTimezoneUtils; | |||
| calendar: DateCalendarUtils; | |||
| form: DateFormUtils; | |||
| charts: DateChartUtils; | |||
| } | |||
| export const DateUtils: DateUtilsInterface = { | |||
| /** | |||
| * Get current date/time | |||
| */ | |||
| now: (): Dayjs => dayjs(), | |||
| /** | |||
| * Parse date from string or Date object | |||
| */ | |||
| parse: (input: DateInput, format?: string): Dayjs => { | |||
| return format ? dayjs(input, format) : dayjs(input); | |||
| }, | |||
| /** | |||
| * Format date for display | |||
| */ | |||
| format: (date: DateInput, format: string = 'YYYY-MM-DD'): string => { | |||
| return dayjs(date).format(format); | |||
| }, | |||
| /** | |||
| * Common date formatting presets | |||
| */ | |||
| formatters: { | |||
| // Dashboard display formats | |||
| shortDate: (date: DateInput): string => dayjs(date).format('MMM DD, YYYY'), | |||
| longDate: (date: DateInput): string => dayjs(date).format('MMMM DD, YYYY'), | |||
| dateTime: (date: DateInput): string => dayjs(date).format('MMM DD, YYYY h:mm A'), | |||
| // Calendar formats | |||
| calendarDate: (date: DateInput): string => dayjs(date).format('YYYY-MM-DD'), | |||
| calendarDateTime: (date: DateInput): string => dayjs(date).format('YYYY-MM-DD HH:mm:ss'), | |||
| // Form input formats | |||
| inputDate: (date: DateInput): string => dayjs(date).format('YYYY-MM-DD'), | |||
| inputDateTime: (date: DateInput): string => dayjs(date).format('YYYY-MM-DDTHH:mm'), | |||
| // Display formats | |||
| timeOnly: (date: DateInput): string => dayjs(date).format('h:mm A'), | |||
| monthYear: (date: DateInput): string => dayjs(date).format('MMMM YYYY'), | |||
| dayMonth: (date: DateInput): string => dayjs(date).format('DD MMM'), | |||
| // Relative time | |||
| relative: (date: DateInput): string => dayjs(date).fromNow(), | |||
| relativeCalendar: (date: DateInput): string => { | |||
| const now = dayjs(); | |||
| const target = dayjs(date); | |||
| const diffDays = now.diff(target, 'day'); | |||
| if (diffDays === 0) return 'Today'; | |||
| if (diffDays === 1) return 'Yesterday'; | |||
| if (diffDays === -1) return 'Tomorrow'; | |||
| if (diffDays > 1 && diffDays < 7) return `${diffDays} days ago`; | |||
| if (diffDays < -1 && diffDays > -7) return `In ${Math.abs(diffDays)} days`; | |||
| return target.format('MMM DD, YYYY'); | |||
| }, | |||
| }, | |||
| /** | |||
| * Date manipulation | |||
| */ | |||
| add: (date: DateInput, amount: number, unit: DateManipulateUnit): Dayjs => | |||
| dayjs(date).add(amount, unit), | |||
| subtract: (date: DateInput, amount: number, unit: DateManipulateUnit): Dayjs => | |||
| dayjs(date).subtract(amount, unit), | |||
| startOf: (date: DateInput, unit: DateUnit): Dayjs => | |||
| dayjs(date).startOf(unit), | |||
| endOf: (date: DateInput, unit: DateUnit): Dayjs => | |||
| dayjs(date).endOf(unit), | |||
| /** | |||
| * Date comparison | |||
| */ | |||
| isBefore: (date1: DateInput, date2: DateInput): boolean => | |||
| dayjs(date1).isBefore(dayjs(date2)), | |||
| isAfter: (date1: DateInput, date2: DateInput): boolean => | |||
| dayjs(date1).isAfter(dayjs(date2)), | |||
| isSame: (date1: DateInput, date2: DateInput, unit: DateUnit = 'day'): boolean => | |||
| dayjs(date1).isSame(dayjs(date2), unit), | |||
| isBetween: (date: DateInput, start: DateInput, end: DateInput): boolean => | |||
| dayjs(date).isBetween(dayjs(start), dayjs(end)), | |||
| /** | |||
| * Date validation | |||
| */ | |||
| isValid: (date: DateInput): boolean => dayjs(date).isValid(), | |||
| /** | |||
| * Timezone utilities | |||
| */ | |||
| timezone: { | |||
| convert: (date: DateInput, tz: string): Dayjs => dayjs(date).tz(tz), | |||
| utc: (date: DateInput): Dayjs => dayjs(date).utc(), | |||
| local: (date: DateInput): Dayjs => dayjs(date).local(), | |||
| guess: (): string => dayjs.tz.guess(), | |||
| }, | |||
| /** | |||
| * Calendar utilities | |||
| */ | |||
| calendar: { | |||
| // Get calendar month data for building calendar views | |||
| getMonthData: (date?: DateInput): CalendarMonth => { | |||
| const target = date ? dayjs(date) : dayjs(); | |||
| const startOfMonth = target.startOf('month'); | |||
| const endOfMonth = target.endOf('month'); | |||
| const startOfCalendar = startOfMonth.startOf('week'); | |||
| const endOfCalendar = endOfMonth.endOf('week'); | |||
| const days: CalendarDay[] = []; | |||
| let current = startOfCalendar; | |||
| while (current.isBefore(endOfCalendar) || current.isSame(endOfCalendar, 'day')) { | |||
| days.push({ | |||
| date: current.format('YYYY-MM-DD'), | |||
| day: current.date(), | |||
| isCurrentMonth: current.isSame(target, 'month'), | |||
| isToday: current.isSame(dayjs(), 'day'), | |||
| dayjs: current.clone(), | |||
| }); | |||
| current = current.add(1, 'day'); | |||
| } | |||
| return { | |||
| month: target.format('MMMM YYYY'), | |||
| year: target.year(), | |||
| monthIndex: target.month(), | |||
| days, | |||
| }; | |||
| }, | |||
| // Get week data | |||
| getWeekData: (date?: DateInput): WeekData => { | |||
| const target = date ? dayjs(date) : dayjs(); | |||
| const startOfWeek = target.startOf('week'); | |||
| const endOfWeek = target.endOf('week'); | |||
| const days: WeekDay[] = []; | |||
| let current = startOfWeek; | |||
| while (current.isBefore(endOfWeek) || current.isSame(endOfWeek, 'day')) { | |||
| days.push({ | |||
| date: current.format('YYYY-MM-DD'), | |||
| day: current.date(), | |||
| dayName: current.format('dddd'), | |||
| shortDayName: current.format('ddd'), | |||
| isToday: current.isSame(dayjs(), 'day'), | |||
| dayjs: current.clone(), | |||
| }); | |||
| current = current.add(1, 'day'); | |||
| } | |||
| return { | |||
| weekStart: startOfWeek.format('MMM DD'), | |||
| weekEnd: endOfWeek.format('MMM DD, YYYY'), | |||
| days, | |||
| }; | |||
| }, | |||
| }, | |||
| /** | |||
| * Form utilities | |||
| */ | |||
| form: { | |||
| // Convert date to HTML5 input format | |||
| toInputValue: (date: DateInput): string => dayjs(date).format('YYYY-MM-DD'), | |||
| toDateTimeInputValue: (date: DateInput): string => dayjs(date).format('YYYY-MM-DDTHH:mm'), | |||
| // Parse from HTML5 input | |||
| fromInputValue: (value: string): Dayjs => dayjs(value), | |||
| // Validate date input | |||
| validateDateInput: (value: string): boolean => { | |||
| const parsed = dayjs(value); | |||
| return parsed.isValid() && value.length >= 8; // Basic validation | |||
| }, | |||
| }, | |||
| /** | |||
| * Chart/Data utilities | |||
| */ | |||
| charts: { | |||
| // Generate date ranges for charts | |||
| generateDateRange: ( | |||
| start: DateInput, | |||
| end: DateInput, | |||
| interval: DateManipulateUnit = 'day' | |||
| ): ChartDatePoint[] => { | |||
| const dates: ChartDatePoint[] = []; | |||
| let current = dayjs(start); | |||
| const endDate = dayjs(end); | |||
| while (current.isBefore(endDate) || current.isSame(endDate, interval)) { | |||
| dates.push({ | |||
| date: current.format('YYYY-MM-DD'), | |||
| label: current.format('MMM DD'), | |||
| value: current.toISOString(), | |||
| dayjs: current.clone(), | |||
| }); | |||
| current = current.add(1, interval); | |||
| } | |||
| return dates; | |||
| }, | |||
| // Get common chart date labels | |||
| getChartLabels: (period: 'week' | 'month' | 'year' = 'week'): string[] => { | |||
| const now = dayjs(); | |||
| switch (period) { | |||
| case 'week': | |||
| return Array.from({ length: 7 }, (_, i) => | |||
| now.subtract(6 - i, 'day').format('ddd') | |||
| ); | |||
| case 'month': | |||
| return Array.from({ length: 30 }, (_, i) => | |||
| now.subtract(29 - i, 'day').format('DD') | |||
| ); | |||
| case 'year': | |||
| return Array.from({ length: 12 }, (_, i) => | |||
| now.subtract(11 - i, 'month').format('MMM') | |||
| ); | |||
| default: | |||
| return []; | |||
| } | |||
| }, | |||
| }, | |||
| }; | |||
| // Export dayjs instance for direct use when needed | |||
| export { dayjs }; | |||
| // Default export | |||
| export default DateUtils; | |||
| @ -0,0 +1,513 @@ | |||
| /** | |||
| * DOM Utility Functions | |||
| * Provides jQuery-like functionality using vanilla JavaScript with TypeScript support | |||
| */ | |||
| import type { DOMUtilities, AnimationOptions } from '../../../types'; | |||
| export type ElementSelector = string | Element | null; | |||
| interface ElementDimensions { | |||
| width: number; | |||
| height: number; | |||
| top: number; | |||
| left: number; | |||
| bottom: number; | |||
| right: number; | |||
| } | |||
| interface SlideAnimationKeyframes { | |||
| height: string; | |||
| } | |||
| interface FadeAnimationKeyframes { | |||
| opacity: number; | |||
| } | |||
| /** | |||
| * Convert string selector to element or return element as-is | |||
| */ | |||
| function getElement(element: ElementSelector): Element | null { | |||
| if (typeof element === 'string') { | |||
| return document.querySelector(element); | |||
| } | |||
| return element; | |||
| } | |||
| /** | |||
| * DOM Utility object with type-safe methods | |||
| */ | |||
| export const DOM: DOMUtilities = { | |||
| /** | |||
| * Document ready (replaces $(document).ready()) | |||
| */ | |||
| ready: (callback: () => void): void => { | |||
| if (document.readyState === 'loading') { | |||
| document.addEventListener('DOMContentLoaded', callback); | |||
| } else { | |||
| callback(); | |||
| } | |||
| }, | |||
| /** | |||
| * Select single element (replaces $('selector')) | |||
| */ | |||
| select: (selector: string, context: Element | Document = document): HTMLElement | null => { | |||
| return context.querySelector(selector); | |||
| }, | |||
| /** | |||
| * Select multiple elements (replaces $('selector')) | |||
| */ | |||
| selectAll: (selector: string, context: Element | Document = document): HTMLElement[] => { | |||
| return Array.from(context.querySelectorAll(selector)); | |||
| }, | |||
| /** | |||
| * Check if element exists | |||
| */ | |||
| exists: (selector: string, context: Element | Document = document): boolean => { | |||
| return context.querySelector(selector) !== null; | |||
| }, | |||
| /** | |||
| * Add event listener (replaces $.on()) | |||
| */ | |||
| on: ( | |||
| element: Element | Window | Document, | |||
| event: string, | |||
| handler: (event: Event) => void, | |||
| options: AddEventListenerOptions = {} | |||
| ): void => { | |||
| if (element) { | |||
| element.addEventListener(event, handler, options); | |||
| } | |||
| }, | |||
| /** | |||
| * Remove event listener (replaces $.off()) | |||
| */ | |||
| off: ( | |||
| element: Element | Window | Document, | |||
| event: string, | |||
| handler: (event: Event) => void | |||
| ): void => { | |||
| if (element) { | |||
| element.removeEventListener(event, handler); | |||
| } | |||
| }, | |||
| /** | |||
| * Add class (replaces $.addClass()) | |||
| */ | |||
| addClass: (element: Element, className: string): void => { | |||
| const el = getElement(element); | |||
| if (el) { | |||
| el.classList.add(className); | |||
| } | |||
| }, | |||
| /** | |||
| * Remove class (replaces $.removeClass()) | |||
| */ | |||
| removeClass: (element: Element, className: string): void => { | |||
| const el = getElement(element); | |||
| if (el) { | |||
| el.classList.remove(className); | |||
| } | |||
| }, | |||
| /** | |||
| * Toggle class (replaces $.toggleClass()) | |||
| */ | |||
| toggleClass: (element: Element, className: string): void => { | |||
| const el = getElement(element); | |||
| if (el) { | |||
| el.classList.toggle(className); | |||
| } | |||
| }, | |||
| /** | |||
| * Check if element has class (replaces $.hasClass()) | |||
| */ | |||
| hasClass: (element: Element, className: string): boolean => { | |||
| const el = getElement(element); | |||
| return el ? el.classList.contains(className) : false; | |||
| }, | |||
| /** | |||
| * Get/Set attribute (replaces $.attr()) | |||
| */ | |||
| attr: (element: Element, name: string, value?: string): string | void => { | |||
| const el = getElement(element); | |||
| if (!el) return; | |||
| if (value === undefined) { | |||
| return el.getAttribute(name) || ''; | |||
| } else { | |||
| el.setAttribute(name, value); | |||
| } | |||
| }, | |||
| /** | |||
| * Get/Set data attribute (replaces $.data()) | |||
| */ | |||
| data: (element: Element, name: string, value?: any): any => { | |||
| const el = getElement(element); | |||
| if (!el) return null; | |||
| const dataName = `data-${name}`; | |||
| if (value === undefined) { | |||
| const attrValue = el.getAttribute(dataName); | |||
| // Try to parse JSON for complex data | |||
| if (attrValue) { | |||
| try { | |||
| return JSON.parse(attrValue); | |||
| } catch { | |||
| return attrValue; | |||
| } | |||
| } | |||
| return null; | |||
| } else { | |||
| const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value); | |||
| el.setAttribute(dataName, stringValue); | |||
| } | |||
| }, | |||
| }; | |||
| /** | |||
| * Extended DOM utilities with additional functionality | |||
| */ | |||
| export const DOMExtended = { | |||
| ...DOM, | |||
| /** | |||
| * Get/Set text content (replaces $.text()) | |||
| */ | |||
| text: (element: ElementSelector, content?: string): string | void => { | |||
| const el = getElement(element); | |||
| if (!el) return; | |||
| if (content === undefined) { | |||
| return el.textContent || ''; | |||
| } else { | |||
| el.textContent = content; | |||
| } | |||
| }, | |||
| /** | |||
| * Get/Set HTML content (replaces $.html()) | |||
| */ | |||
| html: (element: ElementSelector, content?: string): string | void => { | |||
| const el = getElement(element); | |||
| if (!el) return; | |||
| if (content === undefined) { | |||
| return (el as HTMLElement).innerHTML; | |||
| } else { | |||
| (el as HTMLElement).innerHTML = content; | |||
| } | |||
| }, | |||
| /** | |||
| * Hide element (replaces $.hide()) | |||
| */ | |||
| hide: (element: ElementSelector): void => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (el) { | |||
| el.style.display = 'none'; | |||
| } | |||
| }, | |||
| /** | |||
| * Show element (replaces $.show()) | |||
| */ | |||
| show: (element: ElementSelector, display: string = 'block'): void => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (el) { | |||
| el.style.display = display; | |||
| } | |||
| }, | |||
| /** | |||
| * Toggle visibility (replaces $.toggle()) | |||
| */ | |||
| toggle: (element: ElementSelector, display: string = 'block'): void => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (el) { | |||
| if (el.style.display === 'none') { | |||
| el.style.display = display; | |||
| } else { | |||
| el.style.display = 'none'; | |||
| } | |||
| } | |||
| }, | |||
| /** | |||
| * Slide up animation (replaces $.slideUp()) | |||
| */ | |||
| slideUp: (element: ElementSelector, duration: number = 300): Promise<void> => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (!el) return Promise.resolve(); | |||
| return new Promise((resolve) => { | |||
| const height = el.scrollHeight; | |||
| el.style.height = `${height}px`; | |||
| el.style.overflow = 'hidden'; | |||
| const animation = el.animate([ | |||
| { height: `${height}px` } as SlideAnimationKeyframes, | |||
| { height: '0px' } as SlideAnimationKeyframes, | |||
| ], { | |||
| duration, | |||
| easing: 'ease-in-out', | |||
| }); | |||
| animation.onfinish = (): void => { | |||
| el.style.display = 'none'; | |||
| el.style.height = ''; | |||
| el.style.overflow = ''; | |||
| resolve(); | |||
| }; | |||
| }); | |||
| }, | |||
| /** | |||
| * Slide down animation (replaces $.slideDown()) | |||
| */ | |||
| slideDown: (element: ElementSelector, duration: number = 300): Promise<void> => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (!el) return Promise.resolve(); | |||
| return new Promise((resolve) => { | |||
| el.style.display = 'block'; | |||
| el.style.height = '0px'; | |||
| el.style.overflow = 'hidden'; | |||
| const height = el.scrollHeight; | |||
| const animation = el.animate([ | |||
| { height: '0px' } as SlideAnimationKeyframes, | |||
| { height: `${height}px` } as SlideAnimationKeyframes, | |||
| ], { | |||
| duration, | |||
| easing: 'ease-in-out', | |||
| }); | |||
| animation.onfinish = (): void => { | |||
| el.style.height = 'auto'; | |||
| el.style.overflow = 'visible'; | |||
| resolve(); | |||
| }; | |||
| }); | |||
| }, | |||
| /** | |||
| * Fade in animation (replaces $.fadeIn()) | |||
| */ | |||
| fadeIn: (element: ElementSelector, duration: number = 300): Promise<void> => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (!el) return Promise.resolve(); | |||
| return new Promise((resolve) => { | |||
| el.style.opacity = '0'; | |||
| el.style.display = 'block'; | |||
| const animation = el.animate([ | |||
| { opacity: 0 } as FadeAnimationKeyframes, | |||
| { opacity: 1 } as FadeAnimationKeyframes, | |||
| ], { | |||
| duration, | |||
| easing: 'ease-in-out', | |||
| }); | |||
| animation.onfinish = (): void => { | |||
| el.style.opacity = ''; | |||
| resolve(); | |||
| }; | |||
| }); | |||
| }, | |||
| /** | |||
| * Fade out animation (replaces $.fadeOut()) | |||
| */ | |||
| fadeOut: (element: ElementSelector, duration: number = 300): Promise<void> => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (!el) return Promise.resolve(); | |||
| return new Promise((resolve) => { | |||
| const animation = el.animate([ | |||
| { opacity: 1 } as FadeAnimationKeyframes, | |||
| { opacity: 0 } as FadeAnimationKeyframes, | |||
| ], { | |||
| duration, | |||
| easing: 'ease-in-out', | |||
| }); | |||
| animation.onfinish = (): void => { | |||
| el.style.display = 'none'; | |||
| el.style.opacity = ''; | |||
| resolve(); | |||
| }; | |||
| }); | |||
| }, | |||
| /** | |||
| * Get element dimensions and position | |||
| */ | |||
| dimensions: (element: ElementSelector): ElementDimensions | null => { | |||
| const el = getElement(element); | |||
| if (!el) return null; | |||
| const rect = el.getBoundingClientRect(); | |||
| return { | |||
| width: rect.width, | |||
| height: rect.height, | |||
| top: rect.top, | |||
| left: rect.left, | |||
| bottom: rect.bottom, | |||
| right: rect.right, | |||
| }; | |||
| }, | |||
| /** | |||
| * Wait for DOM to be ready (replaces $(document).ready()) | |||
| */ | |||
| ready: (callback: () => void): void => { | |||
| if (document.readyState === 'loading') { | |||
| document.addEventListener('DOMContentLoaded', callback); | |||
| } else { | |||
| callback(); | |||
| } | |||
| }, | |||
| /** | |||
| * Create element with attributes and content | |||
| */ | |||
| create: (tagName: string, attributes?: Record<string, string>, content?: string): HTMLElement => { | |||
| const element = document.createElement(tagName); | |||
| if (attributes) { | |||
| Object.entries(attributes).forEach(([key, value]) => { | |||
| element.setAttribute(key, value); | |||
| }); | |||
| } | |||
| if (content) { | |||
| element.textContent = content; | |||
| } | |||
| return element; | |||
| }, | |||
| /** | |||
| * Append element to parent | |||
| */ | |||
| append: (parent: ElementSelector, child: Element): void => { | |||
| const parentEl = getElement(parent); | |||
| if (parentEl) { | |||
| parentEl.appendChild(child); | |||
| } | |||
| }, | |||
| /** | |||
| * Remove element from DOM | |||
| */ | |||
| remove: (element: ElementSelector): void => { | |||
| const el = getElement(element); | |||
| if (el && el.parentNode) { | |||
| el.parentNode.removeChild(el); | |||
| } | |||
| }, | |||
| /** | |||
| * Get/Set CSS styles | |||
| */ | |||
| css: (element: ElementSelector, property: string, value?: string): string | void => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (!el) return; | |||
| if (value === undefined) { | |||
| return window.getComputedStyle(el).getPropertyValue(property); | |||
| } else { | |||
| el.style.setProperty(property, value); | |||
| } | |||
| }, | |||
| /** | |||
| * Get/Set element value (for form elements) | |||
| */ | |||
| val: (element: ElementSelector, value?: string): string | void => { | |||
| const el = getElement(element) as HTMLInputElement; | |||
| if (!el) return; | |||
| if (value === undefined) { | |||
| return el.value; | |||
| } else { | |||
| el.value = value; | |||
| } | |||
| }, | |||
| /** | |||
| * Trigger custom event | |||
| */ | |||
| trigger: (element: ElementSelector, eventName: string, detail?: any): void => { | |||
| const el = getElement(element); | |||
| if (el) { | |||
| const event = new CustomEvent(eventName, { detail }); | |||
| el.dispatchEvent(event); | |||
| } | |||
| }, | |||
| /** | |||
| * Check if element is visible | |||
| */ | |||
| isVisible: (element: ElementSelector): boolean => { | |||
| const el = getElement(element) as HTMLElement; | |||
| if (!el) return false; | |||
| const style = window.getComputedStyle(el); | |||
| return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; | |||
| }, | |||
| /** | |||
| * Get element offset relative to document | |||
| */ | |||
| offset: (element: ElementSelector): { top: number; left: number } | null => { | |||
| const el = getElement(element); | |||
| if (!el) return null; | |||
| const rect = el.getBoundingClientRect(); | |||
| return { | |||
| top: rect.top + window.pageYOffset, | |||
| left: rect.left + window.pageXOffset, | |||
| }; | |||
| }, | |||
| /** | |||
| * Delegate event handling | |||
| */ | |||
| delegate: ( | |||
| parent: ElementSelector, | |||
| selector: string, | |||
| event: string, | |||
| handler: (event: Event) => void | |||
| ): void => { | |||
| const parentEl = getElement(parent); | |||
| if (parentEl) { | |||
| parentEl.addEventListener(event, (e) => { | |||
| const target = e.target as Element; | |||
| if (target && target.matches(selector)) { | |||
| handler(e); | |||
| } | |||
| }); | |||
| } | |||
| }, | |||
| }; | |||
| // Export both the basic DOM utilities and extended version | |||
| export { DOM as default, DOMExtended }; | |||
| // Re-export types for convenience | |||
| export type { DOMUtilities, ElementSelector, ElementDimensions }; | |||
| @ -0,0 +1,313 @@ | |||
| /** | |||
| * Theme Management Utilities | |||
| * Handles light/dark mode switching with Chart.js integration | |||
| */ | |||
| import type { Theme, ThemeConfig, ThemeColors, ThemeChangeEvent } from '../../../types'; | |||
| declare global { | |||
| interface Window { | |||
| Chart?: any; // Chart.js global object | |||
| } | |||
| } | |||
| interface VectorMapColors { | |||
| backgroundColor: string; | |||
| borderColor: string; | |||
| regionColor: string; | |||
| markerFill: string; | |||
| markerStroke: string; | |||
| hoverColor: string; | |||
| selectedColor: string; | |||
| scaleStart: string; | |||
| scaleEnd: string; | |||
| scaleLight: string; | |||
| scaleDark: string; | |||
| } | |||
| interface SparklineColors { | |||
| success: string; | |||
| purple: string; | |||
| info: string; | |||
| danger: string; | |||
| light: string; | |||
| } | |||
| interface ChartThemeColors { | |||
| textColor: string; | |||
| mutedColor: string; | |||
| borderColor: string; | |||
| gridColor: string; | |||
| tooltipBg: string; | |||
| } | |||
| const THEME_KEY = 'adminator-theme'; | |||
| /** | |||
| * Theme Management Class | |||
| */ | |||
| class ThemeManager { | |||
| private currentTheme: Theme = 'light'; | |||
| private config: ThemeConfig; | |||
| constructor(config?: Partial<ThemeConfig>) { | |||
| this.config = { | |||
| theme: 'light', | |||
| autoDetect: true, | |||
| persistChoice: true, | |||
| ...config, | |||
| }; | |||
| } | |||
| /** | |||
| * Apply theme to the application | |||
| */ | |||
| apply(theme: Theme): void { | |||
| const previousTheme = this.currentTheme; | |||
| this.currentTheme = theme; | |||
| // Set theme attribute on document element | |||
| document.documentElement.setAttribute('data-theme', theme); | |||
| // Update Chart.js defaults if Chart is available | |||
| this.updateChartDefaults(theme); | |||
| // Persist theme choice if enabled | |||
| if (this.config.persistChoice) { | |||
| this.persistTheme(theme); | |||
| } | |||
| // Dispatch theme change event | |||
| this.dispatchThemeChange(theme, previousTheme); | |||
| } | |||
| /** | |||
| * Toggle between light and dark themes | |||
| */ | |||
| toggle(): void { | |||
| const nextTheme: Theme = this.currentTheme === 'dark' ? 'light' : 'dark'; | |||
| this.apply(nextTheme); | |||
| } | |||
| /** | |||
| * Get current theme | |||
| */ | |||
| current(): Theme { | |||
| return this.currentTheme; | |||
| } | |||
| /** | |||
| * Initialize theme system | |||
| */ | |||
| init(): void { | |||
| let initialTheme: Theme = 'light'; | |||
| // Try to load persisted theme | |||
| if (this.config.persistChoice) { | |||
| const persistedTheme = this.getPersistedTheme(); | |||
| if (persistedTheme) { | |||
| initialTheme = persistedTheme; | |||
| } else if (this.config.autoDetect) { | |||
| // Detect OS preference on first visit | |||
| initialTheme = this.detectOSPreference(); | |||
| } | |||
| } | |||
| this.apply(initialTheme); | |||
| } | |||
| /** | |||
| * Get CSS custom property value | |||
| */ | |||
| getCSSVar(varName: string): string { | |||
| return getComputedStyle(document.documentElement) | |||
| .getPropertyValue(varName) | |||
| .trim(); | |||
| } | |||
| /** | |||
| * Get vector map theme colors | |||
| */ | |||
| getVectorMapColors(): VectorMapColors { | |||
| return { | |||
| backgroundColor: this.getCSSVar('--vmap-bg-color'), | |||
| borderColor: this.getCSSVar('--vmap-border-color'), | |||
| regionColor: this.getCSSVar('--vmap-region-color'), | |||
| markerFill: this.getCSSVar('--vmap-marker-fill'), | |||
| markerStroke: this.getCSSVar('--vmap-marker-stroke'), | |||
| hoverColor: this.getCSSVar('--vmap-hover-color'), | |||
| selectedColor: this.getCSSVar('--vmap-selected-color'), | |||
| scaleStart: this.getCSSVar('--vmap-scale-start'), | |||
| scaleEnd: this.getCSSVar('--vmap-scale-end'), | |||
| scaleLight: this.getCSSVar('--vmap-scale-light'), | |||
| scaleDark: this.getCSSVar('--vmap-scale-dark'), | |||
| }; | |||
| } | |||
| /** | |||
| * Get sparkline theme colors | |||
| */ | |||
| getSparklineColors(): SparklineColors { | |||
| return { | |||
| success: this.getCSSVar('--sparkline-success'), | |||
| purple: this.getCSSVar('--sparkline-purple'), | |||
| info: this.getCSSVar('--sparkline-info'), | |||
| danger: this.getCSSVar('--sparkline-danger'), | |||
| light: this.getCSSVar('--sparkline-light'), | |||
| }; | |||
| } | |||
| /** | |||
| * Get chart theme colors | |||
| */ | |||
| getChartColors(): ChartThemeColors { | |||
| const isDark = this.currentTheme === 'dark'; | |||
| return { | |||
| textColor: isDark ? '#FFFFFF' : '#212529', | |||
| mutedColor: isDark ? '#D1D5DB' : '#6C757D', | |||
| borderColor: isDark ? '#374151' : '#E2E5E8', | |||
| gridColor: isDark ? 'rgba(209, 213, 219, 0.15)' : 'rgba(0, 0, 0, 0.05)', | |||
| tooltipBg: isDark ? '#1F2937' : 'rgba(255, 255, 255, 0.95)', | |||
| }; | |||
| } | |||
| /** | |||
| * Update configuration | |||
| */ | |||
| updateConfig(config: Partial<ThemeConfig>): void { | |||
| this.config = { ...this.config, ...config }; | |||
| } | |||
| /** | |||
| * Get current configuration | |||
| */ | |||
| getConfig(): ThemeConfig { | |||
| return { ...this.config }; | |||
| } | |||
| /** | |||
| * Private method: Update Chart.js defaults | |||
| */ | |||
| private updateChartDefaults(theme: Theme): void { | |||
| if (!window.Chart || !window.Chart.defaults) { | |||
| return; | |||
| } | |||
| const isDark = theme === 'dark'; | |||
| const colors = this.getChartColors(); | |||
| try { | |||
| // Set global defaults | |||
| window.Chart.defaults.color = colors.textColor; | |||
| window.Chart.defaults.borderColor = colors.borderColor; | |||
| window.Chart.defaults.backgroundColor = colors.tooltipBg; | |||
| // Set plugin defaults | |||
| if (window.Chart.defaults.plugins?.legend?.labels) { | |||
| window.Chart.defaults.plugins.legend.labels.color = colors.textColor; | |||
| } | |||
| if (window.Chart.defaults.plugins?.tooltip) { | |||
| window.Chart.defaults.plugins.tooltip.backgroundColor = colors.tooltipBg; | |||
| window.Chart.defaults.plugins.tooltip.titleColor = colors.textColor; | |||
| window.Chart.defaults.plugins.tooltip.bodyColor = colors.textColor; | |||
| window.Chart.defaults.plugins.tooltip.borderColor = colors.borderColor; | |||
| } | |||
| // Set scale defaults | |||
| const scaleDefaults = window.Chart.defaults.scales; | |||
| if (scaleDefaults) { | |||
| Object.keys(scaleDefaults).forEach(scaleType => { | |||
| const scale = scaleDefaults[scaleType]; | |||
| if (scale?.ticks) { | |||
| scale.ticks.color = colors.mutedColor; | |||
| } | |||
| if (scale?.grid) { | |||
| scale.grid.color = colors.gridColor; | |||
| } | |||
| if (scale?.pointLabels) { | |||
| scale.pointLabels.color = colors.mutedColor; | |||
| } | |||
| if (scale?.angleLines) { | |||
| scale.angleLines.color = colors.gridColor; | |||
| } | |||
| }); | |||
| } | |||
| } catch (error) { | |||
| console.warn('Error updating Chart.js defaults:', error); | |||
| } | |||
| } | |||
| /** | |||
| * Private method: Persist theme to localStorage | |||
| */ | |||
| private persistTheme(theme: Theme): void { | |||
| try { | |||
| localStorage.setItem(THEME_KEY, theme); | |||
| } catch (error) { | |||
| console.warn('Unable to persist theme:', error); | |||
| } | |||
| } | |||
| /** | |||
| * Private method: Get persisted theme from localStorage | |||
| */ | |||
| private getPersistedTheme(): Theme | null { | |||
| try { | |||
| const theme = localStorage.getItem(THEME_KEY) as Theme; | |||
| return ['light', 'dark'].includes(theme) ? theme : null; | |||
| } catch (error) { | |||
| console.warn('Unable to get persisted theme:', error); | |||
| return null; | |||
| } | |||
| } | |||
| /** | |||
| * Private method: Detect OS color scheme preference | |||
| */ | |||
| private detectOSPreference(): Theme { | |||
| if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { | |||
| return 'dark'; | |||
| } | |||
| return 'light'; | |||
| } | |||
| /** | |||
| * Private method: Dispatch theme change event | |||
| */ | |||
| private dispatchThemeChange(theme: Theme, previousTheme: Theme): void { | |||
| const event: ThemeChangeEvent = new CustomEvent('adminator:themeChanged', { | |||
| detail: { theme, previousTheme }, | |||
| }) as ThemeChangeEvent; | |||
| window.dispatchEvent(event); | |||
| } | |||
| } | |||
| // Create singleton instance | |||
| const themeManager = new ThemeManager(); | |||
| // Export legacy object interface for compatibility | |||
| export const Theme = { | |||
| apply: (theme: Theme) => themeManager.apply(theme), | |||
| toggle: () => themeManager.toggle(), | |||
| current: () => themeManager.current(), | |||
| init: () => themeManager.init(), | |||
| getCSSVar: (varName: string) => themeManager.getCSSVar(varName), | |||
| getVectorMapColors: () => themeManager.getVectorMapColors(), | |||
| getSparklineColors: () => themeManager.getSparklineColors(), | |||
| getChartColors: () => themeManager.getChartColors(), | |||
| }; | |||
| // Export both the manager instance and legacy interface | |||
| export { themeManager as ThemeManager }; | |||
| export default Theme; | |||
| // Export types for external use | |||
| export type { | |||
| Theme as ThemeType, | |||
| ThemeConfig, | |||
| VectorMapColors, | |||
| SparklineColors, | |||
| ChartThemeColors, | |||
| }; | |||
| @ -0,0 +1,542 @@ | |||
| /** | |||
| * Vector Maps Implementation with TypeScript | |||
| * Interactive world map using JSVectorMap with theme support | |||
| */ | |||
| import jsVectorMap from 'jsvectormap'; | |||
| import 'jsvectormap/dist/jsvectormap.css'; | |||
| import 'jsvectormap/dist/maps/world.js'; | |||
| import { debounce } from 'lodash'; | |||
| import { ThemeManager } from '../utils/theme'; | |||
| import type { ComponentInterface } from '../../types'; | |||
| // Type definitions for Vector Maps | |||
| export interface VectorMapMarker { | |||
| name: string; | |||
| coords: [number, number]; | |||
| data?: any; | |||
| } | |||
| export interface VectorMapColors { | |||
| backgroundColor: string; | |||
| regionColor: string; | |||
| borderColor: string; | |||
| hoverColor: string; | |||
| selectedColor: string; | |||
| markerFill: string; | |||
| markerStroke: string; | |||
| scaleStart: string; | |||
| scaleEnd: string; | |||
| textColor: string; | |||
| } | |||
| export interface VectorMapOptions { | |||
| selector: string; | |||
| map: string; | |||
| backgroundColor?: string; | |||
| regionStyle?: { | |||
| initial?: Record<string, any>; | |||
| hover?: Record<string, any>; | |||
| selected?: Record<string, any>; | |||
| }; | |||
| markerStyle?: { | |||
| initial?: Record<string, any>; | |||
| hover?: Record<string, any>; | |||
| }; | |||
| markers?: VectorMapMarker[]; | |||
| series?: { | |||
| regions?: Array<{ | |||
| attribute: string; | |||
| scale: [string, string]; | |||
| normalizeFunction?: string; | |||
| values: Record<string, number>; | |||
| }>; | |||
| }; | |||
| zoomOnScroll?: boolean; | |||
| zoomButtons?: boolean; | |||
| onMarkerTooltipShow?: (event: Event, tooltip: any, index: number) => void; | |||
| onRegionTooltipShow?: (event: Event, tooltip: any, code: string) => void; | |||
| onLoaded?: (map: any) => void; | |||
| } | |||
| export interface VectorMapInstance { | |||
| destroy(): void; | |||
| updateSeries(type: string, config: any): void; | |||
| markers?: VectorMapMarker[]; | |||
| mapData?: any; | |||
| series?: any; | |||
| } | |||
| declare global { | |||
| interface HTMLElement { | |||
| mapInstance?: VectorMapInstance; | |||
| } | |||
| } | |||
| // Enhanced Vector Map implementation | |||
| export class VectorMapComponent implements ComponentInterface { | |||
| public name: string = 'VectorMapComponent'; | |||
| public element: HTMLElement; | |||
| public options: VectorMapOptions; | |||
| public isInitialized: boolean = false; | |||
| private mapInstance: VectorMapInstance | null = null; | |||
| private container: HTMLElement | null = null; | |||
| private resizeObserver: ResizeObserver | null = null; | |||
| private themeChangeHandler: (() => void) | null = null; | |||
| private resizeHandler: (() => void) | null = null; | |||
| private themeManager: typeof ThemeManager; | |||
| constructor(element: HTMLElement, options: Partial<VectorMapOptions> = {}) { | |||
| this.element = element; | |||
| this.options = { | |||
| selector: '#vmap', | |||
| map: 'world', | |||
| backgroundColor: 'transparent', | |||
| zoomOnScroll: false, | |||
| zoomButtons: false, | |||
| markers: [ | |||
| { | |||
| name: 'INDIA : 350', | |||
| coords: [21.00, 78.00], | |||
| }, | |||
| { | |||
| name: 'Australia : 250', | |||
| coords: [-33.00, 151.00], | |||
| }, | |||
| { | |||
| name: 'USA : 250', | |||
| coords: [36.77, -119.41], | |||
| }, | |||
| { | |||
| name: 'UK : 250', | |||
| coords: [55.37, -3.41], | |||
| }, | |||
| { | |||
| name: 'UAE : 250', | |||
| coords: [25.20, 55.27], | |||
| }, | |||
| ], | |||
| ...options, | |||
| }; | |||
| this.themeManager = ThemeManager; | |||
| this.init(); | |||
| } | |||
| public init(): void { | |||
| this.setupContainer(); | |||
| this.setupEventHandlers(); | |||
| this.createMap(); | |||
| this.isInitialized = true; | |||
| } | |||
| public destroy(): void { | |||
| this.cleanup(); | |||
| this.isInitialized = false; | |||
| } | |||
| private setupContainer(): void { | |||
| // Remove existing map | |||
| const existingMap = document.getElementById('vmap'); | |||
| if (existingMap) { | |||
| existingMap.remove(); | |||
| } | |||
| // Create new map container | |||
| this.container = document.createElement('div'); | |||
| this.container.id = 'vmap'; | |||
| this.container.style.height = '490px'; | |||
| this.container.style.position = 'relative'; | |||
| this.container.style.overflow = 'hidden'; | |||
| this.container.style.borderRadius = '8px'; | |||
| this.container.style.border = '1px solid var(--c-border, #d3d9e3)'; | |||
| this.container.style.backgroundColor = 'var(--c-bkg-card, #f9fafb)'; | |||
| this.element.appendChild(this.container); | |||
| } | |||
| private setupEventHandlers(): void { | |||
| // Theme change handler | |||
| this.themeChangeHandler = debounce(this.updateMapTheme.bind(this), 150); | |||
| window.addEventListener('adminator:themeChanged', this.themeChangeHandler); | |||
| // Resize handler | |||
| this.resizeHandler = debounce(this.handleResize.bind(this), 300); | |||
| window.addEventListener('resize', this.resizeHandler); | |||
| // Setup ResizeObserver if available | |||
| if ('ResizeObserver' in window) { | |||
| this.resizeObserver = new ResizeObserver( | |||
| debounce(() => { | |||
| if (this.mapInstance) { | |||
| this.handleResize(); | |||
| } | |||
| }, 300) | |||
| ); | |||
| this.resizeObserver.observe(this.element); | |||
| } | |||
| } | |||
| private createMap(): void { | |||
| if (!this.container) return; | |||
| // Destroy existing map instance | |||
| this.destroyMapInstance(); | |||
| const colors = this.getThemeColors(); | |||
| const mapConfig = this.buildMapConfig(colors); | |||
| try { | |||
| this.mapInstance = jsVectorMap(mapConfig); | |||
| this.element.mapInstance = this.mapInstance; | |||
| } catch (error) { | |||
| console.error('VectorMap: Failed to initialize map', error); | |||
| this.showFallbackContent(colors); | |||
| } | |||
| } | |||
| private getThemeColors(): VectorMapColors { | |||
| const isDark = this.themeManager.current() === 'dark'; | |||
| return { | |||
| backgroundColor: isDark ? '#313644' : '#f9fafb', | |||
| regionColor: isDark ? '#565a5c' : '#e6eaf0', | |||
| borderColor: isDark ? '#72777a' : '#d3d9e3', | |||
| hoverColor: isDark ? '#7774e7' : '#0f9aee', | |||
| selectedColor: isDark ? '#37c936' : '#7774e7', | |||
| markerFill: isDark ? '#0f9aee' : '#7774e7', | |||
| markerStroke: isDark ? '#37c936' : '#0f9aee', | |||
| scaleStart: isDark ? '#b9c2d0' : '#e6eaf0', | |||
| scaleEnd: isDark ? '#0f9aee' : '#007bff', | |||
| textColor: isDark ? '#99abb4' : '#72777a', | |||
| }; | |||
| } | |||
| private buildMapConfig(colors: VectorMapColors): VectorMapOptions { | |||
| return { | |||
| selector: '#vmap', | |||
| map: 'world', | |||
| backgroundColor: this.options.backgroundColor || 'transparent', | |||
| // Region styling | |||
| regionStyle: { | |||
| initial: { | |||
| fill: colors.regionColor, | |||
| stroke: colors.borderColor, | |||
| 'stroke-width': 1, | |||
| 'stroke-opacity': 0.4, | |||
| }, | |||
| hover: { | |||
| fill: colors.hoverColor, | |||
| cursor: 'pointer', | |||
| }, | |||
| selected: { | |||
| fill: colors.selectedColor, | |||
| }, | |||
| ...this.options.regionStyle, | |||
| }, | |||
| // Marker styling | |||
| markerStyle: { | |||
| initial: { | |||
| r: 7, | |||
| fill: colors.markerFill, | |||
| stroke: colors.markerStroke, | |||
| 'stroke-width': 2, | |||
| 'stroke-opacity': 0.4, | |||
| }, | |||
| hover: { | |||
| r: 10, | |||
| fill: colors.hoverColor, | |||
| 'stroke-opacity': 0.8, | |||
| cursor: 'pointer', | |||
| }, | |||
| ...this.options.markerStyle, | |||
| }, | |||
| // Markers data | |||
| markers: this.options.markers || [], | |||
| // Series configuration | |||
| series: this.options.series, | |||
| // Interaction options | |||
| zoomOnScroll: this.options.zoomOnScroll || false, | |||
| zoomButtons: this.options.zoomButtons || false, | |||
| // Event handlers | |||
| onMarkerTooltipShow: this.handleMarkerTooltip.bind(this), | |||
| onRegionTooltipShow: this.handleRegionTooltip.bind(this), | |||
| onLoaded: this.handleMapLoaded.bind(this), | |||
| }; | |||
| } | |||
| private handleMarkerTooltip(event: Event, tooltip: any, index: number): void { | |||
| try { | |||
| const marker = this.mapInstance?.markers?.[index]; | |||
| const markerName = marker?.name || `Marker ${index + 1}`; | |||
| tooltip.text(markerName); | |||
| } catch (error) { | |||
| console.warn('VectorMap: Error in marker tooltip', error); | |||
| } | |||
| // Call custom handler if provided | |||
| if (this.options.onMarkerTooltipShow) { | |||
| this.options.onMarkerTooltipShow(event, tooltip, index); | |||
| } | |||
| } | |||
| private handleRegionTooltip(event: Event, tooltip: any, code: string): void { | |||
| try { | |||
| const mapData = this.mapInstance?.mapData; | |||
| const regionName = mapData?.paths?.[code]?.name || code; | |||
| const series = this.mapInstance?.series?.regions?.[0]; | |||
| const value = series?.values?.[code]; | |||
| const text = value ? `${regionName}: ${value}` : regionName; | |||
| tooltip.text(text); | |||
| } catch (error) { | |||
| console.warn('VectorMap: Error in region tooltip', error); | |||
| tooltip.text(code); | |||
| } | |||
| // Call custom handler if provided | |||
| if (this.options.onRegionTooltipShow) { | |||
| this.options.onRegionTooltipShow(event, tooltip, code); | |||
| } | |||
| } | |||
| private handleMapLoaded(map: any): void { | |||
| console.log('VectorMap: Map loaded successfully'); | |||
| // Call custom handler if provided | |||
| if (this.options.onLoaded) { | |||
| this.options.onLoaded(map); | |||
| } | |||
| } | |||
| private showFallbackContent(colors: VectorMapColors): void { | |||
| if (!this.container) return; | |||
| this.container.innerHTML = ` | |||
| <div style=" | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| height: 100%; | |||
| background: ${colors.backgroundColor}; | |||
| border: 1px solid ${colors.borderColor}; | |||
| border-radius: 8px; | |||
| color: ${colors.textColor}; | |||
| font-size: 14px; | |||
| font-family: system-ui, -apple-system, sans-serif; | |||
| "> | |||
| <div style="text-align: center; padding: 20px;"> | |||
| <div style="font-size: 32px; margin-bottom: 12px;">🗺️</div> | |||
| <div style="font-size: 16px; font-weight: 500; margin-bottom: 8px;">World Map</div> | |||
| <div style="font-size: 12px; opacity: 0.7;">Interactive map will load here</div> | |||
| </div> | |||
| </div> | |||
| `; | |||
| } | |||
| private updateMapTheme(): void { | |||
| if (!this.mapInstance || !this.container) { | |||
| this.createMap(); | |||
| return; | |||
| } | |||
| const colors = this.getThemeColors(); | |||
| try { | |||
| // Update container background | |||
| this.container.style.backgroundColor = colors.backgroundColor; | |||
| this.container.style.borderColor = colors.borderColor; | |||
| // Update series if available | |||
| if (this.mapInstance.series?.regions?.[0]) { | |||
| this.mapInstance.updateSeries('regions', { | |||
| attribute: 'fill', | |||
| scale: [colors.scaleStart, colors.scaleEnd], | |||
| values: this.mapInstance.series.regions[0].values || {}, | |||
| }); | |||
| } | |||
| } catch (error) { | |||
| console.warn('VectorMap: Theme update failed, reinitializing', error); | |||
| this.createMap(); | |||
| } | |||
| } | |||
| private handleResize(): void { | |||
| if (this.mapInstance && this.container) { | |||
| // Force a re-render by recreating the map | |||
| this.createMap(); | |||
| } | |||
| } | |||
| private destroyMapInstance(): void { | |||
| if (this.mapInstance) { | |||
| try { | |||
| this.mapInstance.destroy(); | |||
| } catch (error) { | |||
| console.warn('VectorMap: Error destroying map instance', error); | |||
| } | |||
| this.mapInstance = null; | |||
| } | |||
| } | |||
| private cleanup(): void { | |||
| this.destroyMapInstance(); | |||
| // Remove event listeners | |||
| if (this.themeChangeHandler) { | |||
| window.removeEventListener('adminator:themeChanged', this.themeChangeHandler); | |||
| this.themeChangeHandler = null; | |||
| } | |||
| if (this.resizeHandler) { | |||
| window.removeEventListener('resize', this.resizeHandler); | |||
| this.resizeHandler = null; | |||
| } | |||
| // Disconnect ResizeObserver | |||
| if (this.resizeObserver) { | |||
| this.resizeObserver.disconnect(); | |||
| this.resizeObserver = null; | |||
| } | |||
| // Clear container | |||
| if (this.container && this.container.parentNode) { | |||
| this.container.parentNode.removeChild(this.container); | |||
| this.container = null; | |||
| } | |||
| } | |||
| // Public API methods | |||
| public updateMarkers(markers: VectorMapMarker[]): void { | |||
| this.options.markers = markers; | |||
| this.createMap(); | |||
| } | |||
| public updateSeries(type: string, config: any): void { | |||
| if (this.mapInstance) { | |||
| try { | |||
| this.mapInstance.updateSeries(type, config); | |||
| } catch (error) { | |||
| console.warn('VectorMap: Error updating series', error); | |||
| } | |||
| } | |||
| } | |||
| public getMapInstance(): VectorMapInstance | null { | |||
| return this.mapInstance; | |||
| } | |||
| public refresh(): void { | |||
| this.createMap(); | |||
| } | |||
| public updateOptions(newOptions: Partial<VectorMapOptions>): void { | |||
| this.options = { ...this.options, ...newOptions }; | |||
| this.createMap(); | |||
| } | |||
| } | |||
| // Vector Map Manager | |||
| export class VectorMapManager { | |||
| private instances: Map<string, VectorMapComponent> = new Map(); | |||
| public initialize(selector: string = '#world-map-marker', options: Partial<VectorMapOptions> = {}): VectorMapComponent | null { | |||
| const element = document.querySelector<HTMLElement>(selector); | |||
| if (!element) { | |||
| // Silently return null if element doesn't exist (normal for pages without maps) | |||
| return null; | |||
| } | |||
| // Clean up existing instance | |||
| const existingInstance = this.instances.get(selector); | |||
| if (existingInstance) { | |||
| existingInstance.destroy(); | |||
| } | |||
| // Create new instance | |||
| const vectorMap = new VectorMapComponent(element, options); | |||
| this.instances.set(selector, vectorMap); | |||
| return vectorMap; | |||
| } | |||
| public getInstance(selector: string): VectorMapComponent | undefined { | |||
| return this.instances.get(selector); | |||
| } | |||
| public destroyInstance(selector: string): void { | |||
| const instance = this.instances.get(selector); | |||
| if (instance) { | |||
| instance.destroy(); | |||
| this.instances.delete(selector); | |||
| } | |||
| } | |||
| public destroyAll(): void { | |||
| this.instances.forEach((instance) => { | |||
| instance.destroy(); | |||
| }); | |||
| this.instances.clear(); | |||
| } | |||
| } | |||
| // Create singleton manager | |||
| const vectorMapManager = new VectorMapManager(); | |||
| // Main initialization function | |||
| const vectorMapInit = (): void => { | |||
| // Only initialize if the map container exists | |||
| if (document.querySelector('#world-map-marker')) { | |||
| vectorMapManager.initialize('#world-map-marker', { | |||
| markers: [ | |||
| { | |||
| name: 'INDIA : 350', | |||
| coords: [21.00, 78.00], | |||
| }, | |||
| { | |||
| name: 'Australia : 250', | |||
| coords: [-33.00, 151.00], | |||
| }, | |||
| { | |||
| name: 'USA : 250', | |||
| coords: [36.77, -119.41], | |||
| }, | |||
| { | |||
| name: 'UK : 250', | |||
| coords: [55.37, -3.41], | |||
| }, | |||
| { | |||
| name: 'UAE : 250', | |||
| coords: [25.20, 55.27], | |||
| }, | |||
| ], | |||
| }); | |||
| } | |||
| }; | |||
| // Initialize map | |||
| if (document.readyState === 'loading') { | |||
| document.addEventListener('DOMContentLoaded', vectorMapInit); | |||
| } else { | |||
| vectorMapInit(); | |||
| } | |||
| // Cleanup on page unload | |||
| window.addEventListener('beforeunload', () => { | |||
| vectorMapManager.destroyAll(); | |||
| }); | |||
| // Export default for compatibility | |||
| export default { | |||
| init: vectorMapInit, | |||
| manager: vectorMapManager, | |||
| VectorMapComponent, | |||
| VectorMapManager, | |||
| }; | |||
| @ -0,0 +1,236 @@ | |||
| /** | |||
| * Core type definitions for Adminator Dashboard | |||
| */ | |||
| // Theme types | |||
| export type Theme = 'light' | 'dark' | 'auto'; | |||
| export interface ThemeConfig { | |||
| theme: Theme; | |||
| autoDetect: boolean; | |||
| persistChoice: boolean; | |||
| } | |||
| // Component types | |||
| export interface ComponentOptions { | |||
| [key: string]: any; | |||
| } | |||
| export interface ComponentInterface { | |||
| name: string; | |||
| element: HTMLElement; | |||
| options: ComponentOptions; | |||
| isInitialized: boolean; | |||
| init(): void; | |||
| destroy(): void; | |||
| } | |||
| // Sidebar types | |||
| export interface SidebarOptions { | |||
| breakpoint?: number; | |||
| collapsible?: boolean; | |||
| autoHide?: boolean; | |||
| animation?: boolean; | |||
| animationDuration?: number; | |||
| } | |||
| export interface SidebarState { | |||
| isCollapsed: boolean; | |||
| isMobile: boolean; | |||
| activeMenu: string | null; | |||
| } | |||
| // Chart types | |||
| export type ChartType = 'line' | 'bar' | 'doughnut' | 'pie' | 'radar' | 'scatter' | 'bubble' | 'polarArea'; | |||
| export interface ChartDataset { | |||
| label?: string; | |||
| data: number[]; | |||
| backgroundColor?: string | string[]; | |||
| borderColor?: string | string[]; | |||
| borderWidth?: number; | |||
| fill?: boolean; | |||
| } | |||
| export interface ChartData { | |||
| labels: string[]; | |||
| datasets: ChartDataset[]; | |||
| } | |||
| export interface ChartOptions { | |||
| type: ChartType; | |||
| data: ChartData; | |||
| responsive?: boolean; | |||
| maintainAspectRatio?: boolean; | |||
| plugins?: any; | |||
| scales?: any; | |||
| } | |||
| // DataTable types | |||
| export interface DataTableColumn { | |||
| key: string; | |||
| title: string; | |||
| sortable?: boolean; | |||
| searchable?: boolean; | |||
| render?: (value: any, row: any) => string; | |||
| } | |||
| export interface DataTableOptions { | |||
| columns: DataTableColumn[]; | |||
| data: any[]; | |||
| pageSize?: number; | |||
| sortable?: boolean; | |||
| searchable?: boolean; | |||
| pagination?: boolean; | |||
| } | |||
| export interface DataTableState { | |||
| currentPage: number; | |||
| pageSize: number; | |||
| totalRows: number; | |||
| sortColumn: string | null; | |||
| sortDirection: 'asc' | 'desc'; | |||
| searchQuery: string; | |||
| filteredData: any[]; | |||
| } | |||
| // Date utilities types | |||
| export interface DateRange { | |||
| start: Date; | |||
| end: Date; | |||
| } | |||
| export interface DateFormatOptions { | |||
| locale?: string; | |||
| format?: string; | |||
| timeZone?: string; | |||
| } | |||
| // DOM utilities types | |||
| export type DOMEventHandler = (event: Event) => void; | |||
| export interface DOMUtilities { | |||
| select: (selector: string, context?: Element | Document) => HTMLElement | null; | |||
| selectAll: (selector: string, context?: Element | Document) => HTMLElement[]; | |||
| on: (element: Element | Window | Document, event: string, handler: DOMEventHandler) => void; | |||
| off: (element: Element | Window | Document, event: string, handler: DOMEventHandler) => void; | |||
| addClass: (element: Element, className: string) => void; | |||
| removeClass: (element: Element, className: string) => void; | |||
| toggleClass: (element: Element, className: string) => void; | |||
| hasClass: (element: Element, className: string) => boolean; | |||
| attr: (element: Element, attribute: string, value?: string) => string | void; | |||
| data: (element: Element, key: string, value?: any) => any; | |||
| ready: (callback: () => void) => void; | |||
| exists: (selector: string, context?: Element | Document) => boolean; | |||
| } | |||
| // Application state types | |||
| export interface ApplicationState { | |||
| theme: Theme; | |||
| sidebar: SidebarState; | |||
| components: Map<string, ComponentInterface>; | |||
| isInitialized: boolean; | |||
| } | |||
| export interface ApplicationConfig { | |||
| theme: ThemeConfig; | |||
| sidebar: SidebarOptions; | |||
| enableAnalytics?: boolean; | |||
| debugMode?: boolean; | |||
| } | |||
| // Event types | |||
| export interface CustomEventDetail { | |||
| [key: string]: any; | |||
| } | |||
| export interface ThemeChangeEvent extends CustomEvent { | |||
| detail: { | |||
| theme: Theme; | |||
| previousTheme: Theme; | |||
| }; | |||
| } | |||
| export interface ComponentEvent extends CustomEvent { | |||
| detail: { | |||
| component: string; | |||
| action: 'init' | 'destroy' | 'update'; | |||
| data?: any; | |||
| }; | |||
| } | |||
| // Utility types | |||
| export type DeepPartial<T> = { | |||
| [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]; | |||
| }; | |||
| export type RequiredKeys<T, K extends keyof T> = T & Required<Pick<T, K>>; | |||
| export type OptionalKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; | |||
| // Color types | |||
| export interface ColorPalette { | |||
| primary: string; | |||
| secondary: string; | |||
| success: string; | |||
| danger: string; | |||
| warning: string; | |||
| info: string; | |||
| light: string; | |||
| dark: string; | |||
| } | |||
| export interface ThemeColors { | |||
| light: ColorPalette; | |||
| dark: ColorPalette; | |||
| } | |||
| // Animation types | |||
| export type AnimationEasing = 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'linear'; | |||
| export interface AnimationOptions { | |||
| duration?: number; | |||
| easing?: AnimationEasing; | |||
| delay?: number; | |||
| fillMode?: 'none' | 'forwards' | 'backwards' | 'both'; | |||
| } | |||
| // Layout types | |||
| export interface LayoutBreakpoints { | |||
| xs: number; | |||
| sm: number; | |||
| md: number; | |||
| lg: number; | |||
| xl: number; | |||
| xxl: number; | |||
| } | |||
| export interface ResponsiveConfig { | |||
| breakpoints: LayoutBreakpoints; | |||
| mobileFirst: boolean; | |||
| } | |||
| // Error types | |||
| export class AdminatorError extends Error { | |||
| constructor( | |||
| message: string, | |||
| public component?: string, | |||
| public code?: string | |||
| ) { | |||
| super(message); | |||
| this.name = 'AdminatorError'; | |||
| } | |||
| } | |||
| // Plugin types | |||
| export interface PluginInterface { | |||
| name: string; | |||
| version: string; | |||
| dependencies?: string[]; | |||
| init(app: any): void; | |||
| destroy(): void; | |||
| } | |||
| export interface PluginRegistry { | |||
| [key: string]: PluginInterface; | |||
| } | |||
| @ -0,0 +1,51 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "target": "ES2020", | |||
| "lib": ["ES2020", "DOM", "DOM.Iterable"], | |||
| "module": "ESNext", | |||
| "moduleResolution": "node", | |||
| "allowJs": true, | |||
| "checkJs": false, | |||
| "outDir": "./dist", | |||
| "strict": true, | |||
| "esModuleInterop": true, | |||
| "skipLibCheck": true, | |||
| "forceConsistentCasingInFileNames": true, | |||
| "resolveJsonModule": true, | |||
| "isolatedModules": true, | |||
| "noEmit": true, | |||
| "declaration": true, | |||
| "declarationMap": true, | |||
| "sourceMap": true, | |||
| "removeComments": false, | |||
| "importHelpers": true, | |||
| "experimentalDecorators": true, | |||
| "emitDecoratorMetadata": true, | |||
| "allowSyntheticDefaultImports": true, | |||
| "noImplicitAny": true, | |||
| "noImplicitReturns": true, | |||
| "noImplicitThis": true, | |||
| "noUnusedLocals": true, | |||
| "noUnusedParameters": true, | |||
| "exactOptionalPropertyTypes": true, | |||
| "noImplicitOverride": true, | |||
| "noPropertyAccessFromIndexSignature": false, | |||
| "noUncheckedIndexedAccess": false, | |||
| "baseUrl": "./src", | |||
| "paths": { | |||
| "@/*": ["*"], | |||
| "@/components/*": ["assets/scripts/components/*"], | |||
| "@/utils/*": ["assets/scripts/utils/*"], | |||
| "@/constants/*": ["assets/scripts/constants/*"] | |||
| } | |||
| }, | |||
| "include": [ | |||
| "src/**/*" | |||
| ], | |||
| "exclude": [ | |||
| "node_modules", | |||
| "dist", | |||
| "**/*.spec.ts", | |||
| "**/*.test.ts" | |||
| ] | |||
| } | |||
| @ -1,5 +1,5 @@ | |||
| module.exports = { | |||
| test : /\.(js)$/, | |||
| test : /\.(js|jsx)$/, | |||
| exclude : /(node_modules|build|dist\/)/, | |||
| use : ['babel-loader'], | |||
| }; | |||
| @ -0,0 +1,16 @@ | |||
| module.exports = { | |||
| test: /\.tsx?$/, | |||
| exclude: /(node_modules|build|dist\/)/, | |||
| use: [ | |||
| { | |||
| loader: 'babel-loader', | |||
| }, | |||
| { | |||
| loader: 'ts-loader', | |||
| options: { | |||
| transpileOnly: true, | |||
| experimentalWatchApi: true, | |||
| }, | |||
| }, | |||
| ], | |||
| }; | |||