UNPKG

@andreaswissel/uiflow

Version:

Adaptive UI density management library with progressive disclosure, element dependencies, A/B testing, and intelligent behavior-based adaptation

448 lines (384 loc) 11.5 kB
/** * UIFlow Angular Adapter * Angular services, directives, and components for UIFlow integration */ import { Injectable, Directive, Component, Input, Output, EventEmitter, ElementRef, OnInit, OnDestroy, Inject, InjectionToken, TemplateRef, ViewContainerRef, ChangeDetectorRef, Provider, EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; import { CommonModule } from '@angular/common'; import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { UIFlow } from '../../index.js'; // Injection token for UIFlow configuration export const UIFLOW_CONFIG = new InjectionToken<any>('UIFlow Configuration'); /** * UIFlow Service - Core Angular service for UIFlow integration */ @Injectable({ providedIn: 'root' }) export class UIFlowService implements OnDestroy { private uiflow: UIFlow | null = null; private readonly isReady$ = new BehaviorSubject<boolean>(false); private readonly destroy$ = new Subject<void>(); private readonly densitySubjects = new Map<string, BehaviorSubject<number>>(); private readonly overrideSubjects = new Map<string, BehaviorSubject<boolean>>(); constructor(@Inject(UIFLOW_CONFIG) private config: any = {}) { this.initialize(); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); if (this.uiflow) { this.uiflow.destroy(); } } private async initialize(): Promise<void> { try { this.uiflow = new UIFlow(); await this.uiflow.init(this.config); this.isReady$.next(true); this.setupEventListeners(); } catch (error) { console.error('UIFlow initialization failed:', error); } } private setupEventListeners(): void { // Listen for density changes document.addEventListener('uiflow:density-changed', (event: any) => { const { area, density } = event.detail; this.getDensitySubject(area).next(density); }); // Listen for adaptation events document.addEventListener('uiflow:adaptation', (event: any) => { const { area, newDensity } = event.detail; this.getDensitySubject(area).next(newDensity); }); // Listen for override events document.addEventListener('uiflow:override-applied', (event: any) => { const { area } = event.detail; this.getOverrideSubject(area).next(true); }); document.addEventListener('uiflow:override-cleared', (event: any) => { const { area } = event.detail; this.getOverrideSubject(area).next(false); }); } private getDensitySubject(area: string): BehaviorSubject<number> { if (!this.densitySubjects.has(area)) { const initialDensity = this.uiflow ? this.uiflow.getDensityLevel(area) : 0.3; this.densitySubjects.set(area, new BehaviorSubject(initialDensity)); } return this.densitySubjects.get(area)!; } private getOverrideSubject(area: string): BehaviorSubject<boolean> { if (!this.overrideSubjects.has(area)) { const hasOverride = this.uiflow ? this.uiflow.hasOverride(area) : false; this.overrideSubjects.set(area, new BehaviorSubject(hasOverride)); } return this.overrideSubjects.get(area)!; } // Public API methods get isReady(): Observable<boolean> { return this.isReady$.asObservable(); } get instance(): UIFlow | null { return this.uiflow; } categorize(element: Element, category: string, area: string = 'default', options: any = {}): void { if (this.uiflow) { this.uiflow.categorize(element, category, area, options); } } getDensityLevel(area: string = 'default'): number { return this.uiflow ? this.uiflow.getDensityLevel(area) : 0.3; } getDensity$(area: string = 'default'): Observable<number> { return this.getDensitySubject(area).asObservable(); } setDensityLevel(level: number, area: string = 'default'): void { if (this.uiflow) { this.uiflow.setDensityLevel(level, area); } } hasOverride(area: string): boolean { return this.uiflow ? this.uiflow.hasOverride(area) : false; } hasOverride$(area: string): Observable<boolean> { return this.getOverrideSubject(area).asObservable(); } shouldShowElement(category: string, area: string = 'default'): boolean { return this.uiflow ? this.uiflow.shouldShowElement(category, area) : true; } highlightElement(elementId: string, style: string = 'default', options: any = {}): void { if (this.uiflow) { this.uiflow.highlightElement(elementId, style, options); } } flagAsNew(elementId: string, helpText?: string, duration?: number): void { if (this.uiflow) { this.uiflow.flagAsNew(elementId, helpText, duration); } } simulateUsage(area: string, interactions: any[], days: number = 7): void { if (this.uiflow) { this.uiflow.simulateUsage(area, interactions, days); } } } /** * Directive for automatic element categorization */ @Directive({ selector: '[uiflowElement]', standalone: true }) export class UIFlowElementDirective implements OnInit, OnDestroy { @Input() uiflowElement!: string; // category @Input() uiflowArea: string = 'default'; @Input() uiflowHelpText?: string; @Input() uiflowIsNew: boolean = false; private destroy$ = new Subject<void>(); constructor( private elementRef: ElementRef, private uiflowService: UIFlowService ) {} ngOnInit(): void { this.uiflowService.isReady .pipe(takeUntil(this.destroy$)) .subscribe(ready => { if (ready) { this.categorizeElement(); } }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } private categorizeElement(): void { const options = { helpText: this.uiflowHelpText, isNew: this.uiflowIsNew }; this.uiflowService.categorize( this.elementRef.nativeElement, this.uiflowElement, this.uiflowArea, options ); } } /** * Structural directive for conditional rendering based on density */ @Directive({ selector: '[uiflowIf]', standalone: true }) export class UIFlowIfDirective implements OnInit, OnDestroy { @Input() uiflowIf!: string; // category @Input() uiflowIfArea: string = 'default'; private destroy$ = new Subject<void>(); private hasView = false; constructor( private templateRef: TemplateRef<any>, private viewContainer: ViewContainerRef, private uiflowService: UIFlowService ) {} ngOnInit(): void { this.uiflowService.isReady .pipe(takeUntil(this.destroy$)) .subscribe(ready => { if (ready) { this.updateView(); this.listenForDensityChanges(); } }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } private listenForDensityChanges(): void { this.uiflowService.getDensity$(this.uiflowIfArea) .pipe(takeUntil(this.destroy$)) .subscribe(() => { this.updateView(); }); } private updateView(): void { const shouldShow = this.uiflowService.shouldShowElement(this.uiflowIf, this.uiflowIfArea); if (shouldShow && !this.hasView) { this.viewContainer.createEmbeddedView(this.templateRef); this.hasView = true; } else if (!shouldShow && this.hasView) { this.viewContainer.clear(); this.hasView = false; } } } /** * Component for density level display */ @Component({ selector: 'uiflow-density-indicator', standalone: true, imports: [CommonModule], template: ` <div class="uiflow-density-indicator" [style]="indicatorStyle"> <span>{{ area }}: </span> <span>{{ density | number:'1.0-0' }}%</span> <span *ngIf="showOverride && hasOverride" style="margin-left: 4px; opacity: 0.8"> (override) </span> </div> ` }) export class UIFlowDensityIndicatorComponent implements OnInit, OnDestroy { @Input() area: string = 'default'; @Input() showOverride: boolean = true; density: number = 30; hasOverride: boolean = false; private destroy$ = new Subject<void>(); constructor(private uiflowService: UIFlowService) {} ngOnInit(): void { this.uiflowService.getDensity$(this.area) .pipe(takeUntil(this.destroy$)) .subscribe(density => { this.density = Math.round(density * 100); }); this.uiflowService.hasOverride$(this.area) .pipe(takeUntil(this.destroy$)) .subscribe(hasOverride => { this.hasOverride = hasOverride; }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } get indicatorStyle(): any { return { padding: '4px 8px', backgroundColor: this.hasOverride ? '#fbbf24' : '#3b82f6', color: 'white', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold' }; } } /** * Component for density control slider */ @Component({ selector: 'uiflow-density-control', standalone: true, imports: [CommonModule], template: ` <div class="uiflow-density-control"> <label>{{ area }} Density: {{ density }}%</label> <input type="range" min="0" max="100" [value]="density" (input)="onDensityChange($event)" [disabled]="hasOverride" /> <small *ngIf="hasOverride">Controlled remotely</small> </div> `, styles: [` .uiflow-density-control { display: flex; flex-direction: column; gap: 0.5rem; } input[type="range"] { width: 100%; } input[type="range"]:disabled { opacity: 0.5; } small { color: #6b7280; font-style: italic; } `] }) export class UIFlowDensityControlComponent implements OnInit, OnDestroy { @Input() area: string = 'default'; @Output() densityChange = new EventEmitter<number>(); density: number = 30; hasOverride: boolean = false; private destroy$ = new Subject<void>(); constructor(private uiflowService: UIFlowService) {} ngOnInit(): void { this.uiflowService.getDensity$(this.area) .pipe(takeUntil(this.destroy$)) .subscribe(density => { this.density = Math.round(density * 100); }); this.uiflowService.hasOverride$(this.area) .pipe(takeUntil(this.destroy$)) .subscribe(hasOverride => { this.hasOverride = hasOverride; }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } onDensityChange(event: any): void { const density = parseInt(event.target.value) / 100; this.uiflowService.setDensityLevel(density, this.area); this.densityChange.emit(density); } } /** * Standalone components and directives for UIFlow */ export const UIFLOW_COMPONENTS = [ UIFlowElementDirective, UIFlowIfDirective, UIFlowDensityIndicatorComponent, UIFlowDensityControlComponent ] as const; /** * Provide UIFlow services for Angular applications using standalone APIs */ export function provideUIFlow(config: any = {}): EnvironmentProviders { return makeEnvironmentProviders([ { provide: UIFLOW_CONFIG, useValue: config }, UIFlowService ]); } /** * Legacy NgModule for backwards compatibility (deprecated) * @deprecated Use provideUIFlow() and standalone components instead */ export function provideUIFlowModule() { return { providers: [UIFlowService] }; } // Export Angular version for compatibility check export const ANGULAR_ADAPTER_VERSION = '1.0.0';