'use client'; /** * Router adapter — pluggable navigation backend. * * WHY: * `@djangocfg/ui-core` is framework-agnostic, but consumers using Next.js, * TanStack Router, wouter, etc. need SPA navigations to flow through their * own router so server components / data loaders / route guards fire * correctly. The adapter pattern lets the consumer swap the implementation * without changing call-sites inside library components. * * Default behavior uses `window.history.pushState` + `window.location` (works * in any browser, zero deps). When no provider is mounted, the default is * silently used. * * @example * // In a Next.js app: * import { useRouter as useNextRouter } from 'next/navigation'; * import { RouterAdapterProvider } from '@djangocfg/ui-core/hooks'; * * function NextAdapter({ children }: { children: React.ReactNode }) { * const next = useNextRouter(); * const adapter = useMemo(() => ({ * push: (url: string) => next.push(url), * replace: (url: string) => next.replace(url), * back: () => next.back(), * forward: () => next.forward(), * getLocation: () => ({ * pathname: window.location.pathname, * search: window.location.search, * hash: window.location.hash, * }), * }), [next]); * return {children}; * } */ import { createContext, useContext, useMemo, type ReactNode } from 'react'; /** * Snapshot of the current URL location. * Mirrors the parts of `window.location` we actually use. */ export interface RouterLocation { pathname: string; search: string; hash: string; } /** * Pluggable navigation backend. Implementations must be SSR-safe * (mutations should no-op when `typeof window === 'undefined'`). */ export interface RouterAdapter { /** Push a new entry onto the history stack. */ push: (url: string) => void; /** Replace the current history entry. */ replace: (url: string) => void; /** Go back one entry. */ back: () => void; /** Go forward one entry. */ forward: () => void; /** Read the current location (synchronous). */ getLocation: () => RouterLocation; } const SSR_LOCATION: RouterLocation = Object.freeze({ pathname: '/', search: '', hash: '', }); /** * Default adapter — uses History API + window.location. * No-ops on the server. Triggers our internal `djc:navigate` event so * `useLocation` reflects the change (see `useLocation.ts`). */ export const defaultAdapter: RouterAdapter = Object.freeze({ push(url: string) { if (typeof window === 'undefined') return; window.history.pushState(null, '', url); }, replace(url: string) { if (typeof window === 'undefined') return; window.history.replaceState(null, '', url); }, back() { if (typeof window === 'undefined') return; window.history.back(); }, forward() { if (typeof window === 'undefined') return; window.history.forward(); }, getLocation(): RouterLocation { if (typeof window === 'undefined') return SSR_LOCATION; return { pathname: window.location.pathname, search: window.location.search, hash: window.location.hash, }; }, }); /** * React context carrying the active adapter. `null` means "use default". * Kept intentionally nullable so we don't burn a Provider for the default case. */ export const RouterAdapterContext = createContext(null); export interface RouterAdapterProviderProps { /** Adapter implementation. Will be used by every router hook in subtree. */ value: RouterAdapter; children: ReactNode; } /** * Wrap a subtree to override the navigation backend used by router hooks. */ export function RouterAdapterProvider({ value, children }: RouterAdapterProviderProps) { // Memoizing here is the consumer's job (the value usually comes from another hook). // We don't double-memo — that just adds noise. return ( {children} ); } /** * Read the active router adapter. Returns the default History API adapter * if no provider is mounted. */ export function useRouterAdapter(): RouterAdapter { const ctx = useContext(RouterAdapterContext); // useMemo so consumers can put the returned value in deps without churn. return useMemo(() => ctx ?? defaultAdapter, [ctx]); }