"use client"; import { createContext, useCallback, useContext, useMemo, useRef, useSyncExternalStore, type FC, type ReactNode, } from "react"; import { ComposerInputPluginProvider, useComposerInputPluginRegistryOptional, } from "../ComposerInputPluginContext"; import type { TriggerPopoverResourceOutput } from "./TriggerPopoverResource"; import type { TriggerBehavior } from "./triggerSelectionResource"; export type RegisteredTrigger = { readonly char: string; /** Behavior contributed by a child `TriggerPopover.Directive` / `.Action`. */ readonly behavior?: TriggerBehavior; readonly resource: TriggerPopoverResourceOutput; }; export type TriggerPopoverLifecycleListener = { added(trigger: RegisteredTrigger): void; removed(char: string): void; }; /** * ARIA descriptor of the popover that is currently open. Consumed by the * focused element (typically the composer textarea) so it can advertise the * combobox relationship per the WAI-ARIA editable combobox pattern. */ export type TriggerPopoverActiveAria = { popoverId: string; highlightedItemId: string | undefined; }; export type TriggerPopoverRootContextValue = { register(trigger: RegisteredTrigger): () => void; getTriggers(): ReadonlyMap; subscribe(listener: () => void): () => void; /** Subscribe to per-trigger add/remove events. */ subscribeLifecycle(listener: TriggerPopoverLifecycleListener): () => void; /** ARIA descriptor of the open popover, or null if none is open. */ getActiveAria(): TriggerPopoverActiveAria | null; /** Subscribe to changes in the active ARIA descriptor. */ subscribeAria(listener: () => void): () => void; }; /** * Write-side of the ARIA descriptor, scoped to `TriggerPopover` children of a * `TriggerPopoverRoot`. Intentionally not exposed on the public root context * value: external consumers can read ARIA state but cannot publish or clear it. */ type TriggerPopoverAriaPublish = { setActiveAria(char: string, aria: TriggerPopoverActiveAria | null): void; }; const TriggerPopoverRootContext = createContext(null); const TriggerPopoverAriaPublishContext = createContext(null); export const useTriggerPopoverRootContext = () => { const ctx = useContext(TriggerPopoverRootContext); if (!ctx) throw new Error( "useTriggerPopoverRootContext must be used within ComposerPrimitive.TriggerPopoverRoot", ); return ctx; }; export const useTriggerPopoverRootContextOptional = () => useContext(TriggerPopoverRootContext); /** * Internal hook used by `TriggerPopover` children to publish their open and * highlight state. Not exported from the trigger module. */ export const useTriggerPopoverAriaPublish = (): TriggerPopoverAriaPublish => { const ctx = useContext(TriggerPopoverAriaPublishContext); if (!ctx) throw new Error( "useTriggerPopoverAriaPublish must be used within ComposerPrimitive.TriggerPopoverRoot", ); return ctx; }; /** * Live map of registered triggers, re-rendering on change. Prefer * `subscribeLifecycle` for incremental add/remove handling. */ export const useTriggerPopoverTriggers = () => { const ctx = useTriggerPopoverRootContext(); return useSyncExternalStore(ctx.subscribe, ctx.getTriggers, ctx.getTriggers); }; const EMPTY_TRIGGERS: ReadonlyMap = new Map(); const noopSubscribe = () => () => {}; const getEmptyTriggers = () => EMPTY_TRIGGERS; /** Like `useTriggerPopoverTriggers` but returns an empty map outside a root. */ export const useTriggerPopoverTriggersOptional = () => { const ctx = useTriggerPopoverRootContextOptional(); return useSyncExternalStore( ctx ? ctx.subscribe : noopSubscribe, ctx ? ctx.getTriggers : getEmptyTriggers, ctx ? ctx.getTriggers : getEmptyTriggers, ); }; const getNullAria = () => null; /** * Returns the ARIA descriptor of the currently open trigger popover, or * `null` if none is open or the consumer is rendered outside a * `TriggerPopoverRoot`. */ export const useTriggerPopoverActiveAriaOptional = (): TriggerPopoverActiveAria | null => { const ctx = useTriggerPopoverRootContextOptional(); return useSyncExternalStore( ctx ? ctx.subscribeAria : noopSubscribe, ctx ? ctx.getActiveAria : getNullAria, ctx ? ctx.getActiveAria : getNullAria, ); }; export namespace ComposerPrimitiveTriggerPopoverRoot { export type Props = { children: ReactNode; }; } /** * Local helper for the simple "notify-all listeners" subscribable pattern. * Used twice in this file (trigger registry, active ARIA); kept inline to * avoid pulling a single-use abstraction into the wider tree. */ function useSimpleSubscribable() { const listenersRef = useRef void>>(new Set()); const notify = useCallback(() => { for (const listener of listenersRef.current) listener(); }, []); const subscribe = useCallback((listener: () => void) => { listenersRef.current.add(listener); return () => { listenersRef.current.delete(listener); }; }, []); return { notify, subscribe }; } const TriggerPopoverRootInner: FC< ComposerPrimitiveTriggerPopoverRoot.Props > = ({ children }) => { const triggersRef = useRef>(new Map()); const lifecycleListenersRef = useRef>( new Set(), ); const { notify, subscribe } = useSimpleSubscribable(); const register = useCallback( (trigger) => { const { char } = trigger; if (triggersRef.current.has(char)) { if (process.env.NODE_ENV !== "production") { console.warn( `[assistant-ui] Duplicate TriggerPopover for char "${char}". Ignoring the second registration.`, ); } return () => {}; } if (process.env.NODE_ENV !== "production") { for (const existing of triggersRef.current.values()) { if ( char.startsWith(existing.char) || existing.char.startsWith(char) ) { console.warn( `[assistant-ui] Trigger prefix collision between "${existing.char}" and "${char}". One char is a prefix of the other; only one will match reliably.`, ); } } } const next = new Map(triggersRef.current); next.set(char, trigger); triggersRef.current = next; notify(); for (const l of lifecycleListenersRef.current) l.added(trigger); return () => { const after = new Map(triggersRef.current); after.delete(char); triggersRef.current = after; notify(); for (const l of lifecycleListenersRef.current) l.removed(char); }; }, [notify], ); const getTriggers = useCallback< TriggerPopoverRootContextValue["getTriggers"] >(() => triggersRef.current, []); const subscribeLifecycle = useCallback< TriggerPopoverRootContextValue["subscribeLifecycle"] >((listener) => { lifecycleListenersRef.current.add(listener); return () => { lifecycleListenersRef.current.delete(listener); }; }, []); const activeAriaRef = useRef(null); const activeAriaCharRef = useRef(null); const { notify: notifyAria, subscribe: subscribeAria } = useSimpleSubscribable(); const setActiveAria = useCallback( (char, aria) => { if (aria === null) { if (activeAriaCharRef.current !== char) return; activeAriaRef.current = null; activeAriaCharRef.current = null; notifyAria(); return; } const prev = activeAriaRef.current; if ( activeAriaCharRef.current === char && prev !== null && prev.popoverId === aria.popoverId && prev.highlightedItemId === aria.highlightedItemId ) { return; } activeAriaRef.current = aria; activeAriaCharRef.current = char; notifyAria(); }, [notifyAria], ); const getActiveAria = useCallback< TriggerPopoverRootContextValue["getActiveAria"] >(() => activeAriaRef.current, []); const value = useMemo( () => ({ register, getTriggers, subscribe, subscribeLifecycle, getActiveAria, subscribeAria, }), [ register, getTriggers, subscribe, subscribeLifecycle, getActiveAria, subscribeAria, ], ); const ariaPublishValue = useMemo( () => ({ setActiveAria }), [setActiveAria], ); return ( {children} ); }; /** * Provider that groups one or more `TriggerPopover` declarations. Each trigger * is identified by its `char` (unique within the root). Behavior is contributed * by a child `TriggerPopover.Directive` or `TriggerPopover.Action`. * * @example * ```tsx * * * * ... * * * * * ... * * * * * * * ``` */ export const ComposerPrimitiveTriggerPopoverRoot: FC< ComposerPrimitiveTriggerPopoverRoot.Props > = ({ children }) => { const existingRegistry = useComposerInputPluginRegistryOptional(); if (existingRegistry) { return {children}; } return ( {children} ); }; ComposerPrimitiveTriggerPopoverRoot.displayName = "ComposerPrimitive.TriggerPopoverRoot";