e7469d39ec
- readClaimId: URLSearchParams.get returns '' for ?claim=, not null. Normalize to null so the contract matches the JSDoc and consumers can treat empty as absent. - Tests: regression for ?claim= → null; open/close preserves other query params (?foo=bar survives open/close unchanged). - Tighten pushStateMock/replaceStateMock typing to vi.fn<() => void>() per code-review nit (drops the verbose ReturnType<typeof vi.fn>).
108 lines
4.2 KiB
TypeScript
108 lines
4.2 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
|
|
/**
|
|
* Read the current `?claim=…` query param off `window.location.search`.
|
|
* Returns `null` when the param is absent or empty.
|
|
*
|
|
* `URLSearchParams` is the standard, locale-free way to parse query
|
|
* strings in the browser. Using it (rather than hand-rolled string
|
|
* slicing) means we correctly handle multiple params and percent-encoded
|
|
* characters in claim IDs without surprises.
|
|
*/
|
|
function readClaimId(): string | null {
|
|
const params = new URLSearchParams(window.location.search);
|
|
// `URLSearchParams.get` returns `""` for `?claim=` (param present but
|
|
// empty) — we normalize that to `null` so the "absent" and "empty"
|
|
// cases are indistinguishable to consumers, matching the JSDoc.
|
|
const value = params.get("claim");
|
|
return value === "" ? null : value;
|
|
}
|
|
|
|
/**
|
|
* Build the URL we want to push/replace into history.
|
|
*
|
|
* - `claimId === null` → drop the `?claim=` param, preserving any
|
|
* other params (e.g. `?foo=bar` stays).
|
|
* - `claimId !== null` → set the param to the new id, also preserving
|
|
* any other params.
|
|
*
|
|
* We return `pathname + search + hash` (a relative URL) rather than the
|
|
* full href — `history.pushState` accepts a relative URL and rewriting
|
|
* only the relative form keeps the document's origin stable. `hash` is
|
|
* appended so a `#section` anchor survives navigation, matching what
|
|
* the browser would do natively.
|
|
*/
|
|
function buildUrl(claimId: string | null): string {
|
|
const url = new URL(window.location.href);
|
|
if (claimId === null) {
|
|
url.searchParams.delete("claim");
|
|
} else {
|
|
url.searchParams.set("claim", claimId);
|
|
}
|
|
return url.pathname + url.search + url.hash;
|
|
}
|
|
|
|
/**
|
|
* Per-claim detail drawer URL state (SP4).
|
|
*
|
|
* Reads `?claim=` from the URL on mount and keeps the value in sync with
|
|
* history as the drawer is opened, navigated (j/k), and closed. The
|
|
* drawer parent uses this hook as the single source of truth for "is a
|
|
* claim open and which one" — the URL is the durable record so that
|
|
* deep links (`/claims?claim=CLM-1`) restore the drawer state on reload.
|
|
*
|
|
* - `claimId`: the id parsed from the URL (or `null` when the param
|
|
* is absent). React state so consumers re-render on changes.
|
|
* - `open(id)`: pushes a NEW history entry with `?claim={id}` — so the
|
|
* browser Back button returns to the previous page (e.g. the claims
|
|
* list) and not just to the previously-open claim.
|
|
* - `setClaimId(id)`: REPLACES the current history entry — used by the
|
|
* j/k nav handler so j/k moves through the list without polluting
|
|
* history with one entry per keystroke.
|
|
* - `close()`: pushes a NEW entry that strips the param, so Back from
|
|
* the closed drawer returns to whatever page the user was on before
|
|
* opening the drawer.
|
|
*
|
|
* The hook subscribes to `popstate` so that browser Back/Forward
|
|
* (which fire popstate rather than our own pushState) propagate into
|
|
* the React state. Without this, hitting Back would change the URL but
|
|
* leave the drawer open on the stale id.
|
|
*
|
|
* Implementation note: this is a single-operator local-only tool, so we
|
|
* call `history.pushState` / `replaceState` directly without try/catch
|
|
* wrappers — the only way they throw in modern browsers is when the URL
|
|
* is malformed, which we control.
|
|
*/
|
|
export function useDrawerUrlState(): {
|
|
claimId: string | null;
|
|
open: (id: string) => void;
|
|
close: () => void;
|
|
setClaimId: (id: string) => void;
|
|
} {
|
|
const [claimId, setClaimIdState] = useState<string | null>(() => readClaimId());
|
|
|
|
const open = useCallback((id: string) => {
|
|
window.history.pushState(null, "", buildUrl(id));
|
|
setClaimIdState(id);
|
|
}, []);
|
|
|
|
const setClaimId = useCallback((id: string) => {
|
|
window.history.replaceState(null, "", buildUrl(id));
|
|
setClaimIdState(id);
|
|
}, []);
|
|
|
|
const close = useCallback(() => {
|
|
window.history.pushState(null, "", buildUrl(null));
|
|
setClaimIdState(null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onPopState = () => {
|
|
setClaimIdState(readClaimId());
|
|
};
|
|
window.addEventListener("popstate", onPopState);
|
|
return () => window.removeEventListener("popstate", onPopState);
|
|
}, []);
|
|
|
|
return { claimId, open, close, setClaimId };
|
|
} |