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
JavaScript
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)