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