"use client";
/**
* PresenceBar (TASK-0093, spec R3) — the page/database header avatar bar:
* who's here, colored, with name tooltips, a "+n" overflow (click → a
* popover listing every peer + their state), and the accessible name
* `"{n} people are viewing this page"`.
*
* - Shows **others only** (the local user's peer is excluded by the server
* — it never echoes the sender — and defensively by `selfUserId` here).
* - **Empty state: the bar is absent** (0 others — an empty bar is noise;
* the "N people" label only when ≥ 1 other).
* - The bar is a **plain list, NOT a live region** (it churns too fast for
* screen readers — the announcements live in the separate polite live
* region, spec R5).
* - The color is never the sole identifier: each avatar carries the peer's
* name as its accessible name + tooltip (the a11y rule, ux-req §12).
* - Mobile: the `maxVisible` prop compresses the bar (default 5; a mobile
* layout can pass 3).
*
* The 50-peer avatars-only cap needs no special-casing here: peers with
* `cursor: null` (the stripped mode) still render their avatar — only the
* "editing" descriptor degrades to "viewing".
*/
import { useEffect, useRef, useState } from "react";
import { initials } from "@factory/ui";
import { cn } from "@/lib/utils";
import { presenceColors } from "@/lib/presence-color";
import type { Presence } from "@/lib/types";
export interface PresenceBarProps {
/** The page's OTHER peers (self excluded). */
peers: readonly Presence[];
/** The local user's id (defensively excluded if present in `peers`). */
selfUserId?: string | null;
/** Resolve a cursor's block to a type label for the popover ("editing
* Heading 1"). Omit → the popover says "editing" without a type. */
blockTypeFor?: (blockId: string) => string | null;
/** Max avatars before the "+n" overflow (default 5; pass 3 on mobile). */
maxVisible?: number;
className?: string;
}
/** A peer's popover descriptor: what they're doing right now. */
function describe(peer: Presence, blockTypeFor?: (blockId: string) => string | null): string {
if (peer.viewing === false) return "viewing";
if (peer.cursor) {
const type = blockTypeFor?.(peer.cursor.blockId);
return type ? `editing ${type}` : "editing";
}
return "viewing";
}
function AvatarDot({ peer }: { peer: Presence }) {
const colors = presenceColors(peer.color);
return (
{initials(peer.name) || "?"}
);
}
export function PresenceBar({
peers,
selfUserId,
blockTypeFor,
maxVisible = 5,
className,
}: PresenceBarProps) {
// Others only (the server already excludes self; this is the safety net).
const others = selfUserId ? peers.filter((p) => p.userId !== selfUserId) : peers;
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowRef = useRef(null);
// Close the overflow popover on outside click / Escape.
useEffect(() => {
if (!overflowOpen) return;
const onDown = (e: MouseEvent): void => {
if (overflowRef.current && !overflowRef.current.contains(e.target as Node))
setOverflowOpen(false);
};
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") setOverflowOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [overflowOpen]);
// Empty state: the bar is absent (0 others).
if (others.length === 0) return null;
const visible = others.slice(0, maxVisible);
const overflow = others.slice(maxVisible);
const count = others.length;
const label = `${count} ${count === 1 ? "person" : "people"} ${count === 1 ? "is" : "are"} viewing this page`;
return (
{/* A PLAIN list (not a live region — spec R3: the bar churns too fast
for SR; the announcements live in the separate polite live region).
Each avatar keeps its accessible name (color is never the sole
identifier). */}