UNPKG

tgui-angular

Version:

🚀 Angular UI library for Telegram Web Apps with modern components and theming support | 💼 Author is open to work opportunities | 📧 Contact: a.blagovestnov@gmail.com | 💬 Telegram: @ablagovestnov

1,149 lines (1,131 loc) 824 kB
import * as i1 from '@angular/common'; import { DOCUMENT, CommonModule, NgStyle, NgClass, NgTemplateOutlet } from '@angular/common'; import * as i0 from '@angular/core'; import { Injectable, signal, inject, computed, ElementRef, Renderer2, Directive, ViewContainerRef, effect, Input, HostBinding, input, ChangeDetectionStrategy, Component, HostListener, ChangeDetectorRef, ViewChild, InjectionToken, output, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, NO_ERRORS_SCHEMA, ContentChild, ApplicationRef, EnvironmentInjector, ContentChildren, TemplateRef, model, NgZone, EventEmitter, Output } from '@angular/core'; import { BehaviorSubject, Subject } from 'rxjs'; import * as i2 from '@angular/router'; import { RouterModule } from '@angular/router'; import { offset, flip, autoPlacement, shift, size, arrow, autoUpdate, computePosition } from '@floating-ui/dom'; class TelegramService { ready = new BehaviorSubject(false); initializationTimeout = null; constructor() { this.waitForTelegramWebApp(); } // Check if DOM is available get canUseDOM() { return !!(typeof window !== 'undefined' && window.document && window.document.createElement); } // Get Telegram WebApp data getTelegramData() { if (!this.canUseDOM) { return undefined; } const webApp = window.Telegram?.WebApp; return webApp; } // Wait for Telegram WebApp to be available waitForTelegramWebApp(maxAttempts = 50) { let attempts = 0; const checkWebApp = () => { const webApp = this.getTelegramData(); if (webApp) { this.ready.next(true); if (this.initializationTimeout !== null) { window.clearTimeout(this.initializationTimeout); this.initializationTimeout = null; } } else if (attempts < maxAttempts) { attempts++; this.initializationTimeout = window.setTimeout(checkWebApp, 100); } else { console.warn('TelegramService: Failed to initialize WebApp after', maxAttempts, 'attempts'); this.ready.next(false); } }; checkWebApp(); } // Get ready state as observable get isReady$() { return this.ready.asObservable(); } // Get current ready state get isReady() { return this.ready.value; } // Helper function to convert hex color to RGB hexToRGB(hex) { // Remove # if present hex = hex.replace('#', ''); // Parse hex values const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); return [r, g, b]; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: TelegramService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: TelegramService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: TelegramService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); class ThemeService { // Signal for appearance that components can subscribe to appearance = signal('light'); themeChangeListener = null; mediaQueryList = null; handleThemeChange = null; useSystemTheme = false; currentTheme = 'light'; renderer; telegramService = inject(TelegramService); document = inject(DOCUMENT); constructor(rendererFactory) { this.renderer = rendererFactory.createRenderer(null, null); // Wait for Telegram WebApp to be ready this.telegramService.isReady$.subscribe(isReady => { if (isReady) { this.initializeTheme(); } else { console.warn('themeService: Telegram WebApp is not available, using browser theme'); this.useSystemTheme = true; this.setupBrowserThemeDetection(); } }); } ngOnDestroy() { this.cleanupListeners(); } /** * Change the theme manually * @param theme The theme to set * @param followSystem If true, will follow system theme changes after setting. Default false. */ setTheme(theme, followSystem = false) { this.cleanupListeners(); this.appearance.set(theme); this.applyThemeToDOM(theme); // If instructed to follow system theme, restore the detector if (followSystem) { this.useSystemTheme = true; this.setupBrowserThemeDetection(); } else { this.useSystemTheme = false; } } /** * Setup theme based on inputs and system preferences * @param appearance Appearance to use * @param followSystem Whether to follow system theme */ setupTheme(appearance, followSystem = false) { this.cleanupListeners(); if (followSystem) { // Follow system theme this.useSystemTheme = true; this.setupBrowserThemeDetection(); } else if (appearance) { // Set specific theme this.setTheme(appearance, false); } else { // If nothing specified, try to detect system theme this.detectSystemTheme(); } } /** * Detect and apply system theme */ detectSystemTheme() { if (typeof window === 'undefined') { // Use light theme by default this.setTheme('light', false); return; } const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; this.setTheme(prefersDarkMode ? 'dark' : 'light', false); } /** * Load global CSS variables to make them available for all components */ loadGlobalStyles() { // Check if styles are already loaded if (this.document.getElementById('tgui-variables-css')) { return; } const head = this.document.head; const link = this.document.createElement('link'); link.id = 'tgui-variables-css'; link.rel = 'stylesheet'; link.type = 'text/css'; // Add error handling link.onerror = () => { console.error('Failed to load TGUI variables CSS file. Theme functionality may be limited.'); }; // In production builds, this will be replaced with the actual path // The actual file is bundled with the library during build link.href = 'assets/tgui/styles/variables.css'; head.appendChild(link); } /** * Initialize the theme detection */ initializeTheme() { // First check Telegram API const telegramData = this.telegramService.getTelegramData(); if (telegramData) { // Use Telegram theme this.appearance.set(telegramData.colorScheme); this.applyThemeToDOM(telegramData.colorScheme); // Set up listener for theme changes this.themeChangeListener = () => { const newTelegramData = this.telegramService.getTelegramData(); if (newTelegramData) { this.appearance.set(newTelegramData.colorScheme); this.applyThemeToDOM(newTelegramData.colorScheme); } }; telegramData.onEvent('themeChanged', this.themeChangeListener); } else { // Use browser preference this.useSystemTheme = true; this.setupBrowserThemeDetection(); } } /** * Setup browser theme detection using prefers-color-scheme */ setupBrowserThemeDetection() { if (typeof window === 'undefined') return; const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)'); this.mediaQueryList = isDarkMode; // Apply theme based on system preference only if following system theme if (this.useSystemTheme) { const theme = isDarkMode.matches ? 'dark' : 'light'; this.appearance.set(theme); this.applyThemeToDOM(theme); } // Add listener for theme changes and store reference to the handler this.handleThemeChange = (event) => { if (this.useSystemTheme) { const newTheme = event.matches ? 'dark' : 'light'; this.appearance.set(newTheme); this.applyThemeToDOM(newTheme); } }; isDarkMode.addEventListener('change', this.handleThemeChange); // Store reference for cleanup this.mediaQueryList = isDarkMode; } /** * Apply theme class to DOM */ applyThemeToDOM(theme) { if (theme === this.currentTheme) { if (theme === 'dark') { this.renderer.removeClass(this.document.documentElement, `tgui-theme-light`); } else { this.renderer.removeClass(this.document.documentElement, `tgui-theme-dark`); } return; } ; // Remove existing theme classes this.renderer.removeClass(this.document.documentElement, `tgui-theme-${this.currentTheme}`); // Add the appropriate theme class this.renderer.addClass(this.document.documentElement, `tgui-theme-${theme}`); // Update current theme this.currentTheme = theme; } /** * Clean up event listeners */ cleanupListeners() { // Clean up Telegram listeners const telegramData = this.telegramService.getTelegramData(); if (telegramData && this.themeChangeListener) { telegramData.offEvent('theme_changed', this.themeChangeListener); this.themeChangeListener = null; } // Clean up media query listeners using stored reference if (this.mediaQueryList && this.handleThemeChange) { this.mediaQueryList.removeEventListener('change', this.handleThemeChange); this.mediaQueryList = null; this.handleThemeChange = null; } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: ThemeService, deps: [{ token: i0.RendererFactory2 }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: ThemeService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: ThemeService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i0.RendererFactory2 }] }); class PlatformService { // Signal for platform that components can subscribe to platform = signal('base'); // Cached value for checking iOS platform _isIOSPlatform = null; document = inject(DOCUMENT); constructor() { this.detectPlatform(); } /** * Set platform manually */ setPlatform(platform) { this.platform.set(platform); this.applyPlatformToDOM(platform); // Update cached value this._isIOSPlatform = platform === 'ios'; } /** * Checks if current platform is iOS * Uses DOM class to determine platform, caches result for better performance */ isIOS() { // If value is already cached, return it if (this._isIOSPlatform !== null) { return this._isIOSPlatform; } // Otherwise check for iOS class in DOM if (this.document && this.document.documentElement) { this._isIOSPlatform = this.document.documentElement.classList.contains('tgui-platform-ios'); return this._isIOSPlatform; } // If unable to determine, return false return false; } /** * Detect platform based on user agent */ detectPlatform() { const isIOS = this.detectIOSFromUserAgent(); const detectedPlatform = isIOS ? 'ios' : 'base'; this.platform.set(detectedPlatform); this.applyPlatformToDOM(detectedPlatform); // Cache result this._isIOSPlatform = isIOS; } /** * Apply platform class to DOM */ applyPlatformToDOM(platform) { // Remove existing platform classes this.document.documentElement.classList.remove('tgui-platform-base', 'tgui-platform-ios'); // Add the appropriate platform class this.document.documentElement.classList.add(`tgui-platform-${platform}`); } /** * Check if the device is iOS based on user agent */ detectIOSFromUserAgent() { if (typeof window === 'undefined' || !window.navigator) { return false; } const userAgent = window.navigator.userAgent.toLowerCase(); return /iphone|ipad|ipod/.test(userAgent) || (userAgent.includes('mac') && 'ontouchend' in document); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PlatformService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PlatformService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PlatformService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); /** * Service for managing portal container references * Provides functionality for components to render content outside of their DOM hierarchy */ class PortalService { // Signal to store the portal container reference portalContainerRef = signal(null); // Public readonly signal for accessing the portal container portalContainer = this.portalContainerRef.asReadonly(); // Computed signal to check if portal container is available hasPortalContainer = computed(() => !!this.portalContainer()); constructor() { console.log('PortalService initialized'); } /** * Set the portal container reference * This is typically called by the TGUIRootComponent */ setPortalContainer(elementRef) { if (!elementRef || !elementRef.nativeElement) { console.error('Invalid portal container provided to PortalService'); return; } console.log('Setting portal container:', elementRef.nativeElement); this.portalContainerRef.set(elementRef); } /** * Clear the portal container reference * This should be called when the container is destroyed */ clearPortalContainer() { console.log('Clearing portal container reference'); this.portalContainerRef.set(null); } /** * Get the current portal container element * Returns the native DOM element or null if not set */ getPortalContainerElement() { const container = this.portalContainer()?.nativeElement || null; if (!container) { console.warn('Portal container not available - make sure tgui-root component is properly set up'); } return container; } /** * Check if portal container is ready for use */ isPortalReady() { const isReady = !!this.getPortalContainerElement(); console.log('Portal ready status:', isReady); return isReady; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PortalService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PortalService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PortalService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); class RippleService { RIPPLE_DELAY = 70; WAVE_LIVE = 225; /** * Map to track pointer delay timers by pointerId */ pointerDelayTimers = new Map(); /** * Add a new ripple wave effect at the specified coordinates */ addWave(x, y, pointerId, currentWaves) { const dateNow = Date.now(); // Filter out expired waves const filteredWaves = currentWaves.filter((wave) => wave.date + this.WAVE_LIVE > dateNow); // Add the new wave const newWaves = [ ...filteredWaves, { x, y, date: dateNow, pointerId, } ]; // Clean up the timer for this pointerId this.pointerDelayTimers.delete(pointerId); return newWaves; } /** * Handle pointer down event * @param event Pointer event * @param wavesSignal Signal for managing waves */ handlePointerDown(event, wavesSignal) { const rect = event.currentTarget.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; // Set a timeout to create the ripple effect after a short delay this.pointerDelayTimers.set(event.pointerId, setTimeout(() => { const newWaves = this.addWave(x, y, event.pointerId, wavesSignal()); wavesSignal.set(newWaves); // Clear the waves after they've completed their animation setTimeout(() => { wavesSignal.set([]); }, this.WAVE_LIVE); }, this.RIPPLE_DELAY)); } /** * Handle pointer cancel/up event */ handlePointerCancel(pointerId) { const timer = this.pointerDelayTimers.get(pointerId); if (timer) { clearTimeout(timer); this.pointerDelayTimers.delete(pointerId); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: RippleService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: RippleService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: RippleService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Helper functions for managing TGUI themes */ /** * Apply the specified theme by adding the appropriate CSS class to the document's root element * @param theme The theme to apply ('light' or 'dark') */ function applyTheme(theme) { // Remove existing theme classes document.documentElement.classList.remove('tgui-theme-light', 'tgui-theme-dark'); // Add the appropriate theme class document.documentElement.classList.add(`tgui-theme-${theme}`); } /** * Setup system theme detection using prefers-color-scheme * @param callback Optional callback function that will be called when the theme changes * @returns A function to cleanup the listeners */ function setupSystemThemeDetection(callback) { if (typeof window === 'undefined') return () => { }; const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)'); // Apply initial theme based on system preference const initialTheme = isDarkMode.matches ? 'dark' : 'light'; applyTheme(initialTheme); callback?.(initialTheme); // Add listener for theme changes const handleThemeChange = (event) => { const newTheme = event.matches ? 'dark' : 'light'; applyTheme(newTheme); callback?.(newTheme); }; isDarkMode.addEventListener('change', handleThemeChange); // Return cleanup function return () => { isDarkMode.removeEventListener('change', handleThemeChange); }; } /** * Angular service for using system themes * Uses signals to track theme changes */ class SystemThemeService { // Signal for theme with initial value themeSignal = signal(this.getInitialTheme()); // Public readonly signal for theme access theme = this.themeSignal.asReadonly(); cleanup = null; constructor() { this.setupThemeDetection(); } // Set theme manually setTheme(theme) { this.themeSignal.set(theme); applyTheme(theme); } // Enable system theme tracking enableSystemTheme() { this.cleanup && this.cleanup(); this.setupThemeDetection(); } // Disable system theme tracking disableSystemTheme() { this.cleanup && this.cleanup(); this.cleanup = null; } ngOnDestroy() { this.cleanup && this.cleanup(); } getInitialTheme() { if (typeof window === 'undefined') return 'light'; const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)'); return isDarkMode.matches ? 'dark' : 'light'; } setupThemeDetection() { if (typeof window === 'undefined') return; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); // Set initial theme const initialTheme = mediaQuery.matches ? 'dark' : 'light'; this.themeSignal.set(initialTheme); applyTheme(initialTheme); // Create media query change event handler const handleMediaQueryChange = (event) => { const newTheme = event.matches ? 'dark' : 'light'; this.themeSignal.set(newTheme); applyTheme(newTheme); }; // Add event listener mediaQuery.addEventListener('change', handleMediaQueryChange); // Cleanup function this.cleanup = () => { mediaQuery.removeEventListener('change', handleMediaQueryChange); }; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: SystemThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: SystemThemeService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: SystemThemeService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); /** * Checks if a node exists * * This utility function checks if a passed value can be rendered as a React-like node. * It verifies if the value is not null, not undefined, and is either a primitive or a complex object. * * @param node The node to check * @returns True if the node exists and can be rendered */ function hasNode(node) { return (node !== null && node !== undefined && (typeof node !== 'boolean' || node === true)); } /** * Utility function to combine CSS class names with conditional logic. * Similar to the classnames library in React ecosystem. * * @param classes A list of class names, objects where keys are class names and values are booleans, * or falsy values (which will be ignored) * @returns A string of space-separated class names */ function classNames(...classes) { const result = []; for (const cls of classes) { if (!cls) continue; if (typeof cls === 'string') { result.push(cls); } else if (typeof cls === 'object') { for (const [key, value] of Object.entries(cls)) { if (value) { result.push(key); } } } } return result.join(' '); } /** * Utility function to call multiple event handlers * Similar to the callMultiple function in the React version * * @param handlers List of event handlers to call * @returns A function that calls all handlers with the same arguments */ function callMultiple(...handlers) { return (e) => { handlers.forEach((handler) => { if (typeof handler === 'function') { handler(e); } }); }; } /** * Creates chunks of the given array with specified size * @param array The array to create chunks from * @param chunkSize The size of each chunk * @returns An array of chunks */ function createChunks(array, chunkSize) { const result = []; for (let i = 0; i < array.length; i += chunkSize) { result.push(array.slice(i, i + chunkSize)); } return result; } /** * Directive that automatically loads the TGUI styles * This should be applied once on a root element (typically body or app-root) */ class ThemeDirective { // CSS file path - this will be loaded from assets in the actual build stylesPath = 'assets/tgui/styles/variables.css'; el = inject(ElementRef); renderer = inject(Renderer2); ngOnInit() { this.loadStyles(); } loadStyles() { const head = document.head; const link = document.createElement('link'); link.rel = 'stylesheet'; link.type = 'text/css'; link.href = this.stylesPath; head.appendChild(link); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: ThemeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.8", type: ThemeDirective, isStandalone: true, selector: "[tguiTheme]", ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: ThemeDirective, decorators: [{ type: Directive, args: [{ selector: '[tguiTheme]', standalone: true, }] }] }); /** * Directive that renders content into a portal container * Use it to project content outside of its normal DOM hierarchy */ class PortalOutletDirective { tguiPortalOutlet = null; destroy$ = new Subject(); viewContainerRef = inject(ViewContainerRef); portalService = inject(PortalService); constructor() { effect(() => { const container = this.portalService.portalContainer(); // Skip rendering if we're not initialized yet if (!this.tguiPortalOutlet) return; // Clear existing content this.viewContainerRef.clear(); // Only render if we have both a container and a template if (container && this.tguiPortalOutlet) { const embeddedViewRef = this.viewContainerRef.createEmbeddedView(this.tguiPortalOutlet); // Move the generated content to the portal container const viewRootNodes = embeddedViewRef.rootNodes || []; viewRootNodes.forEach((node) => { container.nativeElement.appendChild(node); }); } }); } ngOnInit() { // Initialization is now handled in the effect } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PortalOutletDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.8", type: PortalOutletDirective, isStandalone: true, selector: "[tguiPortalOutlet]", inputs: { tguiPortalOutlet: "tguiPortalOutlet" }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: PortalOutletDirective, decorators: [{ type: Directive, args: [{ selector: '[tguiPortalOutlet]', standalone: true }] }], ctorParameters: () => [], propDecorators: { tguiPortalOutlet: [{ type: Input }] } }); class HorizontalScrollDirective { display = 'flex'; overflowX = 'scroll'; webkitOverflowScrolling = 'touch'; scrollbarWidth = 'none'; // Firefox msOverflowStyle = 'none'; // IE/Edge hideScrollbar = true; // Chrome/Safari static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: HorizontalScrollDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.8", type: HorizontalScrollDirective, isStandalone: true, selector: "[tguiHorizontalScroll]", host: { properties: { "style.display": "this.display", "style.overflow-x": "this.overflowX", "style.-webkit-overflow-scrolling": "this.webkitOverflowScrolling", "style.scrollbar-width": "this.scrollbarWidth", "style.-ms-overflow-style": "this.msOverflowStyle", "class.tgui-hide-scrollbar": "this.hideScrollbar" } }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: HorizontalScrollDirective, decorators: [{ type: Directive, args: [{ selector: '[tguiHorizontalScroll]', standalone: true }] }], propDecorators: { display: [{ type: HostBinding, args: ['style.display'] }], overflowX: [{ type: HostBinding, args: ['style.overflow-x'] }], webkitOverflowScrolling: [{ type: HostBinding, args: ['style.-webkit-overflow-scrolling'] }], scrollbarWidth: [{ type: HostBinding, args: ['style.scrollbar-width'] }], msOverflowStyle: [{ type: HostBinding, args: ['style.-ms-overflow-style'] }], hideScrollbar: [{ type: HostBinding, args: ['class.tgui-hide-scrollbar'] }] } }); /** * Directive that visually hides an element while keeping it accessible for screen readers. * Used for improving accessibility by providing context for screen reader users * without affecting the visual presentation. */ class VisuallyHiddenDirective { el; constructor(el) { this.el = el; } ngOnInit() { const element = this.el.nativeElement; element.style.position = 'absolute'; element.style.blockSize = '1px'; element.style.inlineSize = '1px'; element.style.padding = '0'; element.style.margin = '-1px'; element.style.whiteSpace = 'nowrap'; element.style.clip = 'rect(0, 0, 0, 0)'; element.style.clipPath = 'inset(50%)'; element.style.overflow = 'hidden'; element.style.border = '0'; element.style.opacity = '0'; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: VisuallyHiddenDirective, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.8", type: VisuallyHiddenDirective, isStandalone: true, selector: "[tguiVisuallyHidden]", ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: VisuallyHiddenDirective, decorators: [{ type: Directive, args: [{ selector: '[tguiVisuallyHidden]', standalone: true }] }], ctorParameters: () => [{ type: i0.ElementRef }] }); class RippleComponent { /** * The collection of active ripple waves */ waves = input([]); static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: RippleComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.8", type: RippleComponent, isStandalone: true, selector: "tgui-ripple", inputs: { waves: { classPropertyName: "waves", publicName: "waves", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: ` <span class="ripple-wrapper" aria-hidden="true" > <span *ngFor="let wave of waves()" class="ripple-wave" [style.top.px]="wave.y" [style.left.px]="wave.x" [attr.data-id]="wave.pointerId" ></span> </span> `, isInline: true, styles: [":host{display:block;position:absolute;width:100%;height:100%;top:0;left:0;pointer-events:none;overflow:hidden}.ripple-wrapper{display:block;overflow:hidden;position:absolute;inset:0;border-radius:inherit;transition:background-color .15s ease-out;pointer-events:none;width:100%;height:100%}.ripple-wave{content:\"\";position:absolute;height:30px;width:30px;margin:-15px 0;border-radius:50%;background:var(--tgui--outline);animation:waveRise .3s cubic-bezier(.3,.3,.5,1);opacity:0}@keyframes waveRise{0%{transform:scale(1);opacity:1}30%{opacity:1}to{transform:scale(20);opacity:0}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: RippleComponent, decorators: [{ type: Component, args: [{ selector: 'tgui-ripple', standalone: true, imports: [CommonModule], template: ` <span class="ripple-wrapper" aria-hidden="true" > <span *ngFor="let wave of waves()" class="ripple-wave" [style.top.px]="wave.y" [style.left.px]="wave.x" [attr.data-id]="wave.pointerId" ></span> </span> `, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block;position:absolute;width:100%;height:100%;top:0;left:0;pointer-events:none;overflow:hidden}.ripple-wrapper{display:block;overflow:hidden;position:absolute;inset:0;border-radius:inherit;transition:background-color .15s ease-out;pointer-events:none;width:100%;height:100%}.ripple-wave{content:\"\";position:absolute;height:30px;width:30px;margin:-15px 0;border-radius:50%;background:var(--tgui--outline);animation:waveRise .3s cubic-bezier(.3,.3,.5,1);opacity:0}@keyframes waveRise{0%{transform:scale(1);opacity:1}30%{opacity:1}to{transform:scale(20);opacity:0}}\n"] }] }] }); class TappableComponent { /** Animation type for clicks */ interactiveAnimation = input('background'); /** Make component read-only */ readonly = input(false); /** Disable component */ disabled = input(false); /** Ripple effect waves */ rippleWaves = signal([]); /** Active state signal for programmatic control */ isActiveState = signal(false); /** Service injections */ rippleService = inject(RippleService); platformService = inject(PlatformService); /** Platform signal */ platformSignal = this.platformService.platform; /** Computed values */ isIOS = computed(() => { return this.platformSignal() === 'ios'; }); isReadOnlyState = computed(() => this.readonly() || this.disabled()); /** Computed ripple effect state */ hasRippleEffect = computed(() => !this.isIOS() && this.interactiveAnimation() === 'background' && !this.isReadOnlyState()); constructor() { // No need for explicit effect since we're using computed signals } ngOnInit() { // Initialization is handled by computed signals } /** * Pointerdown event handler */ onPointerDown(event) { if (!this.isReadOnlyState()) { // Set active state programmatically this.isActiveState.set(true); // Handle ripple effect if needed if (this.hasRippleEffect()) { this.rippleService.handlePointerDown(event, this.rippleWaves); } } } /** * Pointercancel/pointerup/pointerleave event handler */ onPointerCancel(event) { // Always remove active state this.isActiveState.set(false); if (this.hasRippleEffect()) { this.rippleService.handlePointerCancel(event.pointerId); } } /** Host bindings */ get isReadonlyClass() { return this.readonly(); } get isDisabledClass() { return this.disabled(); } get isOpacityAnimation() { return this.interactiveAnimation() === 'opacity' && !this.isIOS(); } get isPlatformIOS() { return this.isIOS(); } get isActive() { return this.isActiveState(); } get readonlyAttr() { return this.readonly() ? '' : null; } get disabledAttr() { return this.disabled() ? '' : null; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: TappableComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.8", type: TappableComponent, isStandalone: true, selector: "tgui-tappable", inputs: { interactiveAnimation: { classPropertyName: "interactiveAnimation", publicName: "interactiveAnimation", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "pointerdown": "onPointerDown($event)", "pointercancel": "onPointerCancel($event)", "pointerup": "onPointerCancel($event)", "pointerleave": "onPointerCancel($event)" }, properties: { "class.readonly": "this.isReadonlyClass", "class.disabled": "this.isDisabledClass", "class.tappable--opacity": "this.isOpacityAnimation", "class.platform-ios": "this.isPlatformIOS", "class.is-active": "this.isActive", "attr.readonly": "this.readonlyAttr", "attr.disabled": "this.disabledAttr" } }, ngImport: i0, template: ` <tgui-ripple *ngIf="hasRippleEffect()" [waves]="rippleWaves()"></tgui-ripple> <ng-content></ng-content> `, isInline: true, styles: [":host{position:relative;isolation:isolate;cursor:pointer;display:block;touch-action:manipulation;border-radius:inherit;overflow:hidden;-webkit-user-select:none;user-select:none}:host:after{content:\"\";position:absolute;inset:0;opacity:0;transition:opacity .15s ease-out;background:var(--tgui--bg_color);border-radius:inherit;pointer-events:none}:host.readonly{cursor:default;pointer-events:none}:host.disabled{cursor:default;opacity:.35;pointer-events:none}:host.platform-ios:after{content:unset}:host.platform-ios:hover{opacity:.85}:host.platform-ios.is-active{opacity:.65}:host:not(.platform-ios).is-active:after{opacity:var(--tgui--button--hovered-opacity, .15)}@media (hover: hover) and (pointer: fine){:host:hover:after{opacity:var(--tgui--button--hovered-opacity, .07)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: RippleComponent, selector: "tgui-ripple", inputs: ["waves"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: TappableComponent, decorators: [{ type: Component, args: [{ selector: 'tgui-tappable', standalone: true, imports: [CommonModule, RippleComponent], template: ` <tgui-ripple *ngIf="hasRippleEffect()" [waves]="rippleWaves()"></tgui-ripple> <ng-content></ng-content> `, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{position:relative;isolation:isolate;cursor:pointer;display:block;touch-action:manipulation;border-radius:inherit;overflow:hidden;-webkit-user-select:none;user-select:none}:host:after{content:\"\";position:absolute;inset:0;opacity:0;transition:opacity .15s ease-out;background:var(--tgui--bg_color);border-radius:inherit;pointer-events:none}:host.readonly{cursor:default;pointer-events:none}:host.disabled{cursor:default;opacity:.35;pointer-events:none}:host.platform-ios:after{content:unset}:host.platform-ios:hover{opacity:.85}:host.platform-ios.is-active{opacity:.65}:host:not(.platform-ios).is-active:after{opacity:var(--tgui--button--hovered-opacity, .15)}@media (hover: hover) and (pointer: fine){:host:hover:after{opacity:var(--tgui--button--hovered-opacity, .07)}}\n"] }] }], ctorParameters: () => [], propDecorators: { onPointerDown: [{ type: HostListener, args: ['pointerdown', ['$event']] }], onPointerCancel: [{ type: HostListener, args: ['pointercancel', ['$event']] }, { type: HostListener, args: ['pointerup', ['$event']] }, { type: HostListener, args: ['pointerleave', ['$event']] }], isReadonlyClass: [{ type: HostBinding, args: ['class.readonly'] }], isDisabledClass: [{ type: HostBinding, args: ['class.disabled'] }], isOpacityAnimation: [{ type: HostBinding, args: ['class.tappable--opacity'] }], isPlatformIOS: [{ type: HostBinding, args: ['class.platform-ios'] }], isActive: [{ type: HostBinding, args: ['class.is-active'] }], readonlyAttr: [{ type: HostBinding, args: ['attr.readonly'] }], disabledAttr: [{ type: HostBinding, args: ['attr.disabled'] }] } }); /** * Component for rendering content in the portal container * Similar to RootRenderer in React version */ class RootPortalComponent { contentTemplate; destroy$ = new Subject(); viewRef = null; templateReady = false; portalService = inject(PortalService); viewContainerRef = inject(ViewContainerRef); cdr = inject(ChangeDetectorRef); constructor() { // Use effect to react to portal container changes effect(() => { const container = this.portalService.portalContainer(); if (!this.templateReady || !this.contentTemplate) { return; } // Clear previous view if it exists if (this.viewRef) { try { this.viewContainerRef.remove(this.viewContainerRef.indexOf(this.viewRef)); } catch (e) { console.error('Error removing portal view:', e); } this.viewRef = null; } if (container && container.nativeElement) { try { // Create and insert view this.viewRef = this.viewContainerRef.createEmbeddedView(this.contentTemplate); this.cdr.detectChanges(); // Move nodes to portal container this.viewRef.rootNodes.forEach((node) => { // Apply font-family to top-level nodes if they're HTML elements if (node instanceof HTMLElement) { node.style.fontFamily = 'var(--tgui--font-family)'; } container.nativeElement.appendChild(node); }); } catch (e) { console.error('Error creating portal view:', e); } } else { console.warn('No portal container available'); } }); } ngAfterViewInit() { // Force immediate check to ensure template detection this.cdr.detectChanges(); // Mark template as ready to use in the effect this.templateReady = true; // Force the effect to run again now that template is ready setTimeout(() => { const container = this.portalService.portalContainer(); // Even if container is the same object, this change // will trigger the effect to run again if (container) { this.portalService.clearPortalContainer(); setTimeout(() => { this.portalService.setPortalContainer(container); }, 0); } }, 0); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); // Clean up view if (this.viewRef) { try { this.viewContainerRef.remove(this.viewContainerRef.indexOf(this.viewRef)); } catch (e) { console.error('Error cleaning up portal view:', e); } } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: RootPortalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.8", type: RootPortalComponent, isStandalone: true, selector: "tgui-root-portal", viewQueries: [{ propertyName: "contentTemplate", first: true, predicate: ["contentTemplate"], descendants: true, static: true }], ngImport: i0, template: ` <ng-template #contentTemplate> <ng-content></ng-content> </ng-template> `, isInline: true, styles: [":host{font-family:var(--tgui--font-family)}:host::ng-deep *{font-family:var(--tgui--font-family)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.8", ngImport: i0, type: RootPortalComponent, decorators: [{ type: Component, args: [{ selector: 'tgui-root-portal', template: ` <ng-template #contentTemplate> <ng-content></ng-content> </ng-template> `, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{font-family:var(--tgui--font-family)}:host::ng-deep *{font-family:var(--tgui--font-family)}\n"] }] }], ctorParameters: () => [], propDecorators: { contentTemplate: [{ type: ViewChild, args: ['contentTemplate', { static: true }] }] } }); /** * Root component for the TGUI library * Provides theming, platform detection, and portal container functionality * Should be used at the root of your application */ class RootComponent { /** Application platform, determined automatically if nothing passed */ platform = input(); /** Application appearance, determined automatically if nothing passed */ appearance = input(); /** Whether to follow system theme changes when appearance is set manually */ followSystemTheme = input(false); // Dependency injection through inject platformService = inject(PlatformService); portalService = inject(PortalService); elementRef = inject((ElementRef)); themeService = inject(ThemeService); renderer = inject(Renderer2); document = inject(DOCUMENT); config = inject(TGUI_CONFIG, { optional: true }); portalContainerEl = null; // Computed host classes based on current theme and platform hostClasses = computed(() => { const currentTheme = this.themeService.appearance(); const currentPlatform = this.platformService.platform(); return { themeLight: currentTheme === 'light', themeDark: currentTheme === 'dark', platformIos: currentPlatform === 'ios', platformBase: currentPlatform === 'base' }; }); constructor() { // Effect to handle platform changes effect(() => { const platformToUse = this.platform() || this.config?.platform; if (platformToUse) { this.platformService.setPlatform(platformToUse); } }); // Effect to handle theme changes effect(() => { const appearanceToUse = this.appearance() || this.config?.appearance; const followSystem = this.followSystemTheme() ?? this.config?.followSystemTheme ?? true; this.themeService.setupTheme(appearanceToUse, followSystem); }); } ngOnInit() { // Load global CSS variables this.themeService.loadGlobalStyles(); } ngAfterViewInit() { // Wait for DOM to be ready before setting up portal container setTimeout(() => { this.setupPortalContainer(); }, 0); } setupPortalContainer() { try { // Create a dedicated div for portal content if it doesn't exist if (!this.portalContainerEl) { this.portalContainerEl = this.document.createElement('div'); this.portalContainerEl.className = 'tgui-portal-container'; this.renderer.appendChild(this.elementRef.nativeElement, this.portalContainerEl); } // Create an ElementRef wrapping the portal container div const portalElementRef = new ElementRef(this.portalContainerEl); // Register as portal container this.portalService.setPortalContainer(portalElementRef); } catch (e)