Comment Pin

Anchored discussion pin rendered at canvas coordinates with author + unread badge.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/comment-pin.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, forwardRef, type ReactNode, } from "react"; import { cn } from "../../lib/utils"; /** * Resolution state of a pinned comment. * * @public */ export type CommentPinState = "open" | "resolved"; /** * Localizable strings. * * @public */ export type CommentPinLabels = { /** Aria-label override. Defaults to `"Comment"`. */ region?: string; /** Suffix appended after the unread count for screen readers. */ unreadSuffix?: string; }; const DEFAULT_LABELS = { region: "Comment", unreadSuffix: "unread", } as const satisfies Required<CommentPinLabels>; /** * Props for {@link CommentPin}. * * @public */ export type CommentPinProps = { /** Optional author initial / glyph rendered inside the pin. */ authorInitial?: ReactNode; /** Optional accent color for the pin. Defaults to the foreground. */ color?: string; /** Localizable strings. */ labels?: CommentPinLabels; /** Click handler — when provided, the pin becomes a button. */ onActivate?: () => void; /** State of the underlying thread. Defaults to `"open"`. */ state?: CommentPinState; /** Optional unread reply count. Renders as a small numeric badge. */ unread?: number; /** Anchor X in canvas pixels. */ x: number; /** Anchor Y in canvas pixels. */ y: number; } & ComponentPropsWithoutRef<"div">; const STATE_FILL: Record<CommentPinState, string> = { open: "bg-foreground text-background", resolved: "bg-muted text-muted-foreground", }; type PinBodyInput = { accent?: string; authorInitial?: ReactNode; state: CommentPinState; unread?: number; }; const PinBody = (props: PinBodyInput): React.ReactElement => { const showBadge = typeof props.unread === "number" && props.unread > 0; const useAccent = props.accent && props.state === "open"; return ( <> <span aria-hidden="true" className={cn( "flex size-7 items-center justify-center rounded-full border border-background text-[11px] font-semibold shadow-sm", STATE_FILL[props.state], )} data-comment-pin-body style={ useAccent ? { backgroundColor: props.accent, color: "white" } : undefined } > {props.authorInitial ?? "•"} </span> {showBadge ? ( <span aria-hidden="true" className="absolute -right-1 -top-1 inline-flex min-h-[14px] min-w-[14px] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] font-medium text-white" data-comment-pin-unread > {props.unread} </span> ) : null} </> ); }; /** * Anchored discussion pin rendered at canvas coordinates. Use to mark * an object-anchored comment thread; click to expand into a * {@link "../thread-bubble/thread-bubble".ThreadBubble} (or whatever the host wires up). * * Pure presentation; the host owns the thread store + supplies the * unread count and resolution state. * * @example * ```tsx * <div className="relative h-screen w-screen"> * <Canvas /> * <CommentPin * x={420} y={180} * authorInitial="B" * unread={3} * onActivate={() => openThread("c-1")} * /> * </div> * ``` * * @public */ export const CommentPin = forwardRef<HTMLDivElement, CommentPinProps>( (props, ref) => { const { authorInitial, className, color, labels, onActivate, state = "open", unread, x, y, ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const showBadge = typeof unread === "number" && unread > 0; const ariaLabel = showBadge ? `${resolvedLabels.region}, ${unread} ${resolvedLabels.unreadSuffix}` : resolvedLabels.region; const handleActivateComment = (): void => { onActivate?.(); }; const body = ( <PinBody accent={color} authorInitial={authorInitial} state={state} unread={unread} /> ); return ( <div aria-label={ariaLabel} className={cn( "absolute z-30 inline-flex -translate-x-1/2 -translate-y-1/2", className, )} data-comment-pin data-comment-pin-state={state} ref={ref} role="img" style={{ left: x, top: y }} {...rest} > {onActivate ? ( <button aria-label={ariaLabel} className="relative inline-flex rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-comment-pin-trigger onClick={handleActivateComment} type="button" > {body} </button> ) : ( <span className="relative inline-flex">{body}</span> )} </div> ); }, ); CommentPin.displayName = "CommentPin";

Dependencies

  • @vllnt/ui@^0.2.1