186 lines
6.6 KiB
TypeScript
186 lines
6.6 KiB
TypeScript
import {
|
|
type AnyRouter,
|
|
Outlet,
|
|
useRouter,
|
|
useRouterState,
|
|
useParams as useTanstackParams,
|
|
} from "@tanstack/react-router";
|
|
import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from "react";
|
|
import { createContext, forwardRef, useCallback, useContext } from "react";
|
|
|
|
// Keep URL search values as strings while the console's existing links move to TanStack Router.
|
|
export function parseUrlSearch(value: string): Record<string, string | string[]> {
|
|
const result: Record<string, string | string[]> = Object.create(null);
|
|
for (const [key, part] of new URLSearchParams(value)) {
|
|
const previous = result[key];
|
|
result[key] = previous === undefined ? part : [...(Array.isArray(previous) ? previous : [previous]), part];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function stringifyUrlSearch(search: Record<string, unknown>): string {
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(search)) {
|
|
if (value == null) continue;
|
|
for (const part of Array.isArray(value) ? value : [value]) params.append(key, String(part));
|
|
}
|
|
const value = params.toString();
|
|
return value ? `?${value}` : "";
|
|
}
|
|
|
|
export type Location = {
|
|
pathname: string;
|
|
search: string;
|
|
hash: string;
|
|
state: unknown;
|
|
key: string;
|
|
};
|
|
const TestLocation = createContext<Location | null>(null);
|
|
let navigationSequence = 0;
|
|
|
|
declare module "@tanstack/react-router" {
|
|
interface HistoryState {
|
|
__umtNavigationKey?: string;
|
|
}
|
|
}
|
|
|
|
export function useLocation(): Location {
|
|
const testLocation = useContext(TestLocation);
|
|
// The test provider is fixed for a render tree; production always uses RouterProvider.
|
|
if (testLocation) return testLocation;
|
|
// biome-ignore lint/correctness/useHookAtTopLevel: provider branch is stable for the component lifetime
|
|
const location = useRouterState({ select: (state) => state.location });
|
|
return {
|
|
pathname: location.pathname,
|
|
search: location.searchStr,
|
|
hash: location.hash ? (location.hash.startsWith("#") ? location.hash : `#${location.hash}`) : "",
|
|
state: location.state,
|
|
key: location.state.__TSR_key ?? location.href,
|
|
};
|
|
}
|
|
|
|
type To = string | { pathname?: string; search?: string; hash?: string };
|
|
type NavigationOptions = { replace?: boolean; state?: unknown; preventScrollReset?: boolean };
|
|
|
|
export function navigateTo(router: AnyRouter, target: URL, options: NavigationOptions = {}) {
|
|
return router.navigate({
|
|
to: target.pathname,
|
|
search: parseUrlSearch(target.search),
|
|
hash: target.hash.slice(1),
|
|
replace: options.replace,
|
|
resetScroll: !options.preventScrollReset,
|
|
// 显式导航即使地址和业务 state 相同,也要更新 key,让设置搜索能再次定位同一字段。
|
|
state: {
|
|
...((options.state ?? {}) as Record<string, unknown>),
|
|
__umtNavigationKey: `${Date.now()}:${++navigationSequence}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function resolveTo(to: To, current: Pick<Location, "pathname" | "search" | "hash">): string {
|
|
if (typeof to === "string") {
|
|
if (to.startsWith("?") || to.startsWith("#"))
|
|
return `${current.pathname}${to.startsWith("#") ? current.search : ""}${to}`;
|
|
return to;
|
|
}
|
|
return `${to.pathname ?? current.pathname}${to.search ?? ""}${to.hash ?? ""}`;
|
|
}
|
|
|
|
export function useNavigate() {
|
|
const router = useRouter({ warn: false });
|
|
const { pathname, search, hash } = useLocation();
|
|
return useCallback(
|
|
(to: To | number, options: NavigationOptions = {}) => {
|
|
if (typeof to === "number") {
|
|
router?.history.go(to);
|
|
return;
|
|
}
|
|
const target = new URL(resolveTo(to, { pathname, search, hash }), window.location.origin);
|
|
if (target.origin !== window.location.origin) {
|
|
window.location.assign(target.href);
|
|
return;
|
|
}
|
|
if (!router) return;
|
|
void navigateTo(router, target, options);
|
|
},
|
|
[router, pathname, search, hash],
|
|
);
|
|
}
|
|
|
|
type SearchInput = URLSearchParams | Record<string, string>;
|
|
export function useSearchParams(): [
|
|
URLSearchParams,
|
|
(next: SearchInput | ((current: URLSearchParams) => SearchInput), options?: NavigationOptions) => void,
|
|
] {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const set = useCallback(
|
|
(next: SearchInput | ((current: URLSearchParams) => SearchInput), options?: NavigationOptions) => {
|
|
const value = typeof next === "function" ? next(new URLSearchParams(location.search)) : next;
|
|
const params = value instanceof URLSearchParams ? value : new URLSearchParams(value);
|
|
navigate({ search: params.toString() ? `?${params}` : "" }, options);
|
|
},
|
|
[location.search, navigate],
|
|
);
|
|
return [new URLSearchParams(location.search), set];
|
|
}
|
|
|
|
export function useParams<T extends Record<string, string | undefined> = Record<string, string | undefined>>(): T {
|
|
const testLocation = useContext(TestLocation);
|
|
if (testLocation) return {} as T;
|
|
// biome-ignore lint/correctness/useHookAtTopLevel: provider branch is stable for the component lifetime
|
|
return useTanstackParams({ strict: false } as never) as T;
|
|
}
|
|
|
|
export type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
|
|
to: To;
|
|
replace?: boolean;
|
|
state?: unknown;
|
|
preventScrollReset?: boolean;
|
|
};
|
|
|
|
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
|
|
{ to, replace, state, preventScrollReset, onClick, target, children, ...props },
|
|
ref,
|
|
) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const href = resolveTo(to, location);
|
|
const click = (event: MouseEvent<HTMLAnchorElement>) => {
|
|
onClick?.(event);
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.button !== 0 ||
|
|
event.metaKey ||
|
|
event.ctrlKey ||
|
|
event.shiftKey ||
|
|
event.altKey ||
|
|
target === "_blank" ||
|
|
props.download
|
|
)
|
|
return;
|
|
const url = new URL(href, window.location.origin);
|
|
if (url.origin !== window.location.origin) return;
|
|
event.preventDefault();
|
|
navigate(href, { replace, state, preventScrollReset });
|
|
};
|
|
return (
|
|
<a {...props} ref={ref} href={href} target={target} onClick={click}>
|
|
{children}
|
|
</a>
|
|
);
|
|
});
|
|
|
|
export function redirect(to: string): Response {
|
|
return new Response(null, { status: 302, headers: { Location: to } });
|
|
}
|
|
|
|
export { Outlet };
|
|
|
|
// Existing component tests mount only a memory navigation context.
|
|
export function MemoryRouter({ children, initialEntries }: { children?: ReactNode; initialEntries?: string[] }) {
|
|
const url = new URL(initialEntries?.[0] ?? "/", "http://localhost");
|
|
const location: Location = { pathname: url.pathname, search: url.search, hash: url.hash, state: null, key: "test" };
|
|
return <TestLocation value={location}>{children}</TestLocation>;
|
|
}
|