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
*
*

*
*
*
* ```
*/
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 = [];
}
}