import { useEffect, useRef, useState, type RefObject } from 'react'; /** * Minimal shape every `@42/core` controller satisfies. Mirrors * `HeadlessController` from the core package: it is constructed against an * existing DOM root and tears every side effect down on `destroy()`. `update()` * is optional — adapters call it to sync reactive options without re-creating * the instance and otherwise re-create. */ export interface C42Controller { /** Remove every listener / side effect. Must be idempotent. */ destroy(): void; /** Optional: sync reactive options without re-creating the instance. */ update?(options: unknown): void; } /** Constructor shape for a headless controller. * * `options` is declared as required so controllers with mandatory options * (e.g. pagination's `total`) are assignable; controllers with optional * options remain assignable too (an optional parameter satisfies a required * one). The hook itself tolerates a missing options object at runtime. */ export type C42ControllerClass< O = unknown, E extends HTMLElement = HTMLElement, > = new (root: E, options: O) => C42Controller; /** A DOM CustomEvent handler that receives the already-unwrapped `detail`. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type C42EventHandler = (detail: any, event: CustomEvent) => void; /** * Bridge a vanilla-TS `@42/core` controller into React. * * On mount it instantiates `new ControllerClass(el, options)` against the * returned ref. On cleanup it calls `destroy()`. When any value in `deps` * changes it re-syncs the controller: if the instance exposes `update()` that * is called with the latest options; otherwise the instance is destroyed and * re-created. * * SSR-safe: the DOM is only touched inside `useEffect`, never during render. * * @param ControllerClass the headless controller constructor. * @param options options forwarded to the constructor / `update()`. * @param deps values that should trigger an options re-sync. * @returns a ref to attach to the controller's root element. */ export function useC42( ControllerClass: C42ControllerClass, options?: O, deps: unknown[] = [], ): RefObject { const ref = useRef(null); const instanceRef = useRef(null); // Always keep the latest options without forcing a re-create on every render. const optionsRef = useRef(options); optionsRef.current = options; // Bumping this token forces the create/destroy effect to run again. const [recreateToken, setRecreateToken] = useState(0); const firstSyncRef = useRef(true); // Create / destroy. Re-runs when the controller class or recreate token change. useEffect(() => { const el = ref.current; if (!el) return; const instance = new ControllerClass(el, optionsRef.current as O); instanceRef.current = instance; return () => { try { instance.destroy(); } finally { instanceRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ControllerClass, recreateToken]); // Re-sync when deps change: prefer update(), otherwise re-create. useEffect(() => { if (firstSyncRef.current) { firstSyncRef.current = false; return; } const instance = instanceRef.current; if (instance && typeof instance.update === 'function') { instance.update(optionsRef.current); } else { setRecreateToken((token) => token + 1); } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return ref; } /** * Attach `data-c42-*` DOM CustomEvent listeners to a controller root. * * Listeners are bound once per mount (event names are static for a given * component); the latest handler functions are read through a ref so passing * fresh callbacks every render does not re-bind. Each handler receives the * unwrapped `event.detail` plus the original `CustomEvent`. */ export function useC42Events( ref: RefObject, handlers: Record, ): void { const handlersRef = useRef(handlers); handlersRef.current = handlers; // Event names are stable across renders for a generated wrapper. const names = Object.keys(handlers); const nameKey = names.join('|'); useEffect(() => { const el = ref.current; if (!el) return; const bound = names.map((name) => { const listener = (event: Event) => { const fn = handlersRef.current[name]; if (fn) fn((event as CustomEvent).detail, event as CustomEvent); }; el.addEventListener(name, listener); return [name, listener] as const; }); return () => { for (const [name, listener] of bound) el.removeEventListener(name, listener); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref, nameKey]); }