'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]);
}