import {
AVATARGROUP_CHANGE_EVENT,
type AvatarGroupChangeDetail,
type AvatarGroupOptions,
type AvatarGroupSpacing,
} from './avatar-group.types';
const SELECTORS = {
avatar: '[data-c42-avatar]',
overflow: '[data-c42-avatar-overflow]',
} as const;
/**
* Headless avatar-group controller. Lays out a set of `[data-c42-avatar]`
* children as an overlapping stack and optionally collapses the overflow into
* a `[data-c42-avatar-overflow]` chip. It manages state, ARIA and `data-*`
* only; the overlap visuals live entirely in CSS, which reacts to
* `data-stacked` / `data-spacing`.
*
* Markup:
* ```html
*
* ```
*/
export class AvatarGroup {
private readonly root: HTMLElement;
private readonly overflowEl: HTMLElement | null;
private stacked: boolean;
private spacing: AvatarGroupSpacing;
private maxVisible: number;
private label: string | null;
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: AvatarGroupOptions = {}) {
if (root.querySelectorAll(SELECTORS.avatar).length === 0) {
throw new Error('[42/avatar-group] Needs at least one [data-c42-avatar] child.');
}
this.root = root;
this.overflowEl = root.querySelector(SELECTORS.overflow);
this.stacked = options.stacked ?? true;
this.spacing = options.spacing ?? 'md';
this.maxVisible = Math.max(0, Math.floor(options.max ?? 0));
this.label = options.label ?? null;
this.init();
}
private init(): void {
this.root.setAttribute('role', 'group');
if (this.label) {
this.root.setAttribute('aria-label', this.label);
}
this.render();
}
/** Direct-child avatars, in document order (excludes the overflow chip). */
private avatars(): HTMLElement[] {
return Array.from(this.root.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(SELECTORS.avatar),
);
}
private counts(): { total: number; visible: number; overflow: number } {
const total = this.avatars().length;
const visible = this.maxVisible > 0 ? Math.min(this.maxVisible, total) : total;
return { total, visible, overflow: total - visible };
}
private render(): void {
this.root.dataset.stacked = String(this.stacked);
this.root.dataset.spacing = this.spacing;
const all = this.avatars();
const { total, visible, overflow } = this.counts();
all.forEach((el, index) => {
el.toggleAttribute('hidden', this.maxVisible > 0 && index >= visible);
});
if (this.overflowEl) {
if (overflow > 0) {
this.overflowEl.textContent = `+${overflow}`;
this.overflowEl.removeAttribute('hidden');
this.overflowEl.setAttribute('aria-label', `${overflow} more`);
} else {
this.overflowEl.setAttribute('hidden', '');
this.overflowEl.removeAttribute('aria-label');
}
}
this.root.dataset.total = String(total);
this.root.dataset.visible = String(visible);
this.root.dataset.overflow = String(overflow);
}
private emit(): void {
const detail: AvatarGroupChangeDetail = this.counts();
this.root.dispatchEvent(new CustomEvent(AVATARGROUP_CHANGE_EVENT, { detail, bubbles: true }));
}
/** Toggle the overlapping stack layout. */
setStacked(stacked: boolean): void {
if (stacked === this.stacked) {
return;
}
this.stacked = stacked;
this.render();
}
/** Change the overlap distance preset. */
setSpacing(spacing: AvatarGroupSpacing): void {
if (spacing === this.spacing) {
return;
}
this.spacing = spacing;
this.render();
}
/** Set the maximum visible avatars (`0` clears the limit). */
setMax(max: number): void {
const next = Math.max(0, Math.floor(max));
if (next === this.maxVisible) {
return;
}
this.maxVisible = next;
this.render();
this.emit();
}
/** Recompute visibility/overflow after avatars are added or removed. */
refresh(): void {
this.render();
this.emit();
}
get total(): number {
return this.avatars().length;
}
/** 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 = [];
}
}