import { AVATAR_ERROR_EVENT, AVATAR_LOAD_EVENT, AVATAR_STATUSCHANGE_EVENT, type AvatarOptions, type AvatarStatus, type AvatarStatusChangeDetail, } from './avatar.types'; type AvatarState = 'loading' | 'loaded' | 'error'; const SELECTORS = { image: '[data-c42-avatar-image]', fallback: '[data-c42-avatar-fallback]', status: '[data-c42-avatar-status]', } as const; /** * Headless avatar controller. Tracks image loading and falls back to initials * when no image is available, and reflects an optional presence status. It * manages `data-state` and ARIA only; visual styling is left to CSS. * * Markup: * ```html *
* Jane Doe * * *
* ``` */ export class Avatar { private readonly root: HTMLElement; private readonly image: HTMLImageElement | null; private readonly fallback: HTMLElement | null; private readonly statusEl: HTMLElement | null; private name: string; private status: AvatarStatus | null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: AvatarOptions = {}) { const image = root.querySelector(SELECTORS.image); const fallback = root.querySelector(SELECTORS.fallback); if (!image && !fallback) { throw new Error( '[42/avatar] Needs a [data-c42-avatar-image] or [data-c42-avatar-fallback] element.', ); } this.root = root; this.image = image; this.fallback = fallback; this.statusEl = root.querySelector(SELECTORS.status); this.name = options.name ?? image?.alt ?? ''; this.status = options.status ?? null; this.init(); } private init(): void { this.root.setAttribute('role', 'img'); if (this.name) { this.root.setAttribute('aria-label', this.name); } if (this.fallback && !this.fallback.textContent?.trim() && this.name) { this.fallback.textContent = this.initials(this.name); } this.renderStatus(); if (this.image) { const onLoad = (): void => this.handleLoad(); const onError = (): void => this.handleError(); this.image.addEventListener('load', onLoad); this.image.addEventListener('error', onError); this.cleanups.push(() => { this.image!.removeEventListener('load', onLoad); this.image!.removeEventListener('error', onError); }); const hasSrc = !!this.image.getAttribute('src'); if (!hasSrc) { this.setState('error'); } else if (this.image.complete && this.image.naturalWidth > 0) { this.setState('loaded'); } else { this.setState('loading'); } } else { this.setState('error'); } } private initials(name: string): string { return name .trim() .split(/\s+/) .slice(0, 2) .map((word) => word.charAt(0).toUpperCase()) .join(''); } private setState(state: AvatarState): void { this.root.dataset.state = state; } private handleLoad(): void { this.setState('loaded'); this.root.dispatchEvent(new CustomEvent(AVATAR_LOAD_EVENT, { bubbles: true })); } private handleError(): void { this.setState('error'); this.root.dispatchEvent(new CustomEvent(AVATAR_ERROR_EVENT, { bubbles: true })); } private renderStatus(): void { if (!this.statusEl) { return; } if (this.status) { this.statusEl.dataset.status = this.status; this.statusEl.setAttribute('aria-label', this.status); } else { delete this.statusEl.dataset.status; this.statusEl.removeAttribute('aria-label'); } } /** Update the presence status. Pass `null` to clear it. */ setStatus(status: AvatarStatus | null): void { this.status = status; this.renderStatus(); const detail: AvatarStatusChangeDetail = { status }; this.root.dispatchEvent(new CustomEvent(AVATAR_STATUSCHANGE_EVENT, { detail, bubbles: true })); } /** Swap the image source and reset tracking to the loading state. */ setSrc(url: string): void { if (!this.image) { return; } this.setState('loading'); this.image.setAttribute('src', url); } get value(): AvatarStatus | null { return this.status; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }