import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, input, numberAttribute, signal, viewChild, } from '@angular/core'; import type { IconName } from '@africanies/africanies-icons'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; /** Corner treatment for {@link ImageComponent}. */ export type ImageShape = 'circle' | 'rounded' | 'square'; /** How the image fills its frame. */ export type ImageFit = 'cover' | 'contain'; /** * Image with a shaped loading frame and fallback while the asset fetches or if * it fails to render. * * @example * ```html * * * * ``` */ @Component({ selector: 'africanies-image', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [AfricaniesIconComponent], host: { class: 'inline-block', }, template: `
@if (src(); as url) { } @if (showPlaceholder()) { }
`, }) /** * Image with a shaped loading frame and fallback while the asset fetches or if it fails to render. */ export class ImageComponent { private readonly imgRef = viewChild>('img'); /** Image URL. When empty, the fallback or placeholder icon is shown. */ readonly src = input(null); readonly alt = input(''); /** Frame size and layout classes, e.g. `h-16 w-16 shrink-0`. */ readonly frameClass = input('h-40 w-40'); readonly shape = input('rounded'); readonly fit = input('cover'); /** Short fallback label — initials, file type, etc. */ readonly fallback = input(undefined); readonly fallbackClass = input('text-body-sm'); readonly placeholderIcon = input('picture'); readonly iconSize = input(20, { transform: numberAttribute }); protected readonly loaded = signal(false); protected readonly failed = signal(false); protected readonly pending = computed(() => { const url = this.src()?.trim(); if (!url) { return false; } return !this.loaded() && !this.failed(); }); protected readonly showPlaceholder = computed(() => { const url = this.src()?.trim(); if (!url) { return true; } return this.pending() || this.failed(); }); protected readonly frameShellClass = computed(() => { const shape = this.shape(); const rounded = shape === 'circle' ? 'rounded-full' : shape === 'rounded' ? 'rounded-lg' : ''; return `${this.frameClass()} ${rounded}`.trim(); }); protected readonly fitClass = computed(() => this.fit() === 'contain' ? 'object-contain' : 'object-cover', ); constructor() { effect(() => { this.src(); this.loaded.set(false); this.failed.set(false); queueMicrotask(() => this.syncIfAlreadyLoaded()); }); } protected onLoad(): void { this.loaded.set(true); this.failed.set(false); } protected onError(): void { this.failed.set(true); this.loaded.set(false); } private syncIfAlreadyLoaded(): void { const el = this.imgRef()?.nativeElement; if (el?.complete && el.naturalWidth > 0) { this.onLoad(); } } }