"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). */} {overflow.length > 0 ? (
{overflowOpen ? (

{count} viewing

    {others.map((p) => (
  • {p.name} {describe(p, blockTypeFor)}
  • ))}
) : null}
) : null}
); }