diff options
Diffstat (limited to 'src/adapters/history/index.ts')
| -rw-r--r-- | src/adapters/history/index.ts | 93 |
1 files changed, 93 insertions, 0 deletions
diff --git a/src/adapters/history/index.ts b/src/adapters/history/index.ts new file mode 100644 index 0000000..2886053 --- /dev/null +++ b/src/adapters/history/index.ts @@ -0,0 +1,93 @@ +/** + * History adapter — the injected browser effect for client-side routing. + * + * A thin wrapper over `window.history` + the `popstate` event that exposes the + * current pathname, a `navigate` (pushState) that updates the URL without a + * reload, and a `subscribe` for path changes (back/forward buttons + programmatic + * navigation). The route SEMANTICS (path → workspace/home) live in the + * `workspaces` feature's pure `parsePath`; this adapter deals only in path + * strings, so it stays generic + reusable. + * + * The browser edge (`window`/`history`) is INJECTED (defaults to the global) so + * it is testable without the DOM and degrades to a no-op when absent (SSR / no + * `window`). + */ + +/** The minimal browser-history surface this adapter needs. */ +export interface HistoryWindow { + readonly location: { readonly pathname: string }; + readonly history: { + pushState(data: unknown, unused: string, url?: string | URL | null): void; + replaceState(data: unknown, unused: string, url?: string | URL | null): void; + }; + addEventListener(type: "popstate", listener: () => void): void; + removeEventListener(type: "popstate", listener: () => void): void; +} + +export interface HistoryAdapter { + /** The current pathname. */ + readonly path: string; + /** Push a new path (updates the URL without a reload) + notifies subscribers. */ + navigate(path: string): void; + /** Replace the current path without adding a history entry (no notify). */ + replace(path: string): void; + /** Subscribe to path changes (back/forward + navigate). Returns unsubscribe. */ + subscribe(cb: (path: string) => void): () => void; +} + +export interface CreateHistoryOptions { + /** The browser window; defaults to `globalThis`. Inject for tests. */ + readonly window?: HistoryWindow; +} + +function noop(): void {} + +/** A no-op adapter for when there is no `window` (SSR). */ +function createNoopHistory(): HistoryAdapter { + return { + get path() { + return "/"; + }, + navigate: noop, + replace: noop, + subscribe() { + return noop; + }, + }; +} + +export function createHistoryAdapter(opts?: CreateHistoryOptions): HistoryAdapter { + const w = opts?.window ?? (globalThis as unknown as HistoryWindow); + if (w === undefined || w === null || w.location === undefined) { + return createNoopHistory(); + } + + const listeners = new Set<(path: string) => void>(); + const current = (): string => w.location.pathname; + const emit = (): void => { + const p = current(); + for (const cb of listeners) cb(p); + }; + + return { + get path() { + return current(); + }, + navigate(path: string): void { + w.history.pushState(null, "", path); + emit(); + }, + replace(path: string): void { + w.history.replaceState(null, "", path); + }, + subscribe(cb: (path: string) => void): () => void { + listeners.add(cb); + const onPop = (): void => cb(current()); + w.addEventListener("popstate", onPop); + return () => { + listeners.delete(cb); + w.removeEventListener("popstate", onPop); + }; + }, + }; +} |
