mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
ui-next: navigation
This commit is contained in:
6 files changed
+156
-16
No files matched your search
@@ -1,7 +1,11 @@
|
|||||||
|
// Components
|
||||||
|
export { Link, type LinkProps } from './src/components/link';
|
||||||
|
|
||||||
// Context
|
// Context
|
||||||
export { type PageData, usePageData } from './src/context/page-data';
|
export { type NavigationState, type PageData, useNavigationState, usePageData } from './src/context/page-data';
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
|
export { useNavigate } from './src/hooks/use-navigate';
|
||||||
export { useUrl } from './src/hooks/use-url';
|
export { useUrl } from './src/hooks/use-url';
|
||||||
|
|
||||||
// Registry
|
// Registry
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { usePageData } from './context/page-data';
|
|
||||||
import { Component } from './registry';
|
|
||||||
|
|
||||||
const AppInner = Component('page:app', () => {
|
|
||||||
const data = usePageData();
|
|
||||||
|
|
||||||
return <div>app, page:{data.name}</div>;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default AppInner;
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Link } from './components/link';
|
||||||
|
import { usePageData } from './context/page-data';
|
||||||
|
import { Component } from './registry';
|
||||||
|
|
||||||
|
const AppInner = Component('page:app', () => {
|
||||||
|
const data = usePageData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>app, page:{data.name}</div>
|
||||||
|
<div>
|
||||||
|
<Link to="homepage">homepage</Link> <Link to="problem_main">problem_main</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AppInner;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from '../hooks/use-navigate';
|
||||||
|
import { useUrl } from '../hooks/use-url';
|
||||||
|
|
||||||
|
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
||||||
|
/** Pre-built href. Use this or `to`, not both. */
|
||||||
|
href?: string;
|
||||||
|
/** Route name to resolve via the route map. */
|
||||||
|
to?: string;
|
||||||
|
/** Params for route resolution when `to` is given. */
|
||||||
|
params?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModifiedEvent(e: React.MouseEvent): boolean {
|
||||||
|
return e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Link: React.FC<React.PropsWithChildren<LinkProps>> = ({ href, to, params, onClick, children, ...rest }) => {
|
||||||
|
const buildUrl = useUrl();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const resolvedHref = useMemo(() => (to ? buildUrl(to, params) : (href ?? '#')), [buildUrl, href, to, params]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
onClick?.(e);
|
||||||
|
if (e.defaultPrevented || isModifiedEvent(e)) return;
|
||||||
|
// Let the browser handle external links and javascript/mailto URIs
|
||||||
|
if (!resolvedHref.startsWith('/') && !resolvedHref.startsWith(window.location.origin)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(resolvedHref);
|
||||||
|
},
|
||||||
|
[onClick, resolvedHref, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={resolvedHref} onClick={handleClick} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
|
||||||
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
|
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export interface PageData {
|
export interface PageData {
|
||||||
@@ -9,11 +11,17 @@ export interface PageData {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageDataSetter = (data: PageData) => void;
|
type PageDataSetter = React.Dispatch<React.SetStateAction<PageData>>;
|
||||||
|
|
||||||
interface PageDataContextValue {
|
interface PageDataContextValue {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
setData: PageDataSetter;
|
setData: PageDataSetter;
|
||||||
|
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
|
||||||
|
error: Error | null;
|
||||||
|
setError: React.Dispatch<React.SetStateAction<Error | null>>;
|
||||||
}
|
}
|
||||||
const PageDataContext = createContext<PageDataContextValue | null>(null);
|
const PageDataContext = createContext<PageDataContextValue | null>(null);
|
||||||
|
|
||||||
@@ -24,14 +32,34 @@ interface PageDataProviderProps {
|
|||||||
|
|
||||||
export function PageDataProvider({ initial, children }: PageDataProviderProps) {
|
export function PageDataProvider({ initial, children }: PageDataProviderProps) {
|
||||||
const [data, setData] = useState<PageData>(initial);
|
const [data, setData] = useState<PageData>(initial);
|
||||||
const value = useMemo(() => ({ data, setData }), [data]);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const value = useMemo(() => ({ data, setData, loading, setLoading, error, setError }), [data, loading, error]);
|
||||||
|
|
||||||
return <PageDataContext.Provider value={value}>{children}</PageDataContext.Provider>;
|
return <PageDataContext.Provider value={value}>{children}</PageDataContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
function usePageDataContext(): PageDataContextValue {
|
||||||
export function usePageData(): PageData {
|
|
||||||
const ctx = useContext(PageDataContext);
|
const ctx = useContext(PageDataContext);
|
||||||
if (!ctx) throw new Error('usePageData must be used within PageDataProvider');
|
if (!ctx) throw new Error('usePageData must be used within PageDataProvider');
|
||||||
return ctx.data;
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageData(): PageData {
|
||||||
|
return usePageDataContext().data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationState {
|
||||||
|
loading: PageDataContextValue['loading'];
|
||||||
|
error: PageDataContextValue['error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNavigationState(): NavigationState {
|
||||||
|
const { loading, error } = usePageDataContext();
|
||||||
|
return { loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNavigationControls() {
|
||||||
|
const { setData, setLoading, setError } = usePageDataContext();
|
||||||
|
return { setData, setLoading, setError };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { type PageData, useNavigationControls } from '../context/page-data';
|
||||||
|
|
||||||
|
function buildReqUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
u.searchParams.set('_ui_next', '1');
|
||||||
|
return u.pathname + u.search + u.hash;
|
||||||
|
} catch {
|
||||||
|
const sep = url.includes('?') ? '&' : '?';
|
||||||
|
return `${url}${sep}_ui_next=1`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOrigin(url: string): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(url, window.location.origin).origin === window.location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNavigate() {
|
||||||
|
const { setData, setLoading, setError } = useNavigationControls();
|
||||||
|
|
||||||
|
const navigate = useCallback(async (url: string) => {
|
||||||
|
if (!isSameOrigin(url)) {
|
||||||
|
window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const fetchUrl = buildReqUrl(url);
|
||||||
|
const res = await fetch(fetchUrl, { headers: { Accept: 'application/json', 'x-hydro-inject': 'uicontext,usercontext' } });
|
||||||
|
if (res.redirected) {
|
||||||
|
window.location.href = res.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Navigation failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const data: PageData = await res.json();
|
||||||
|
console.log('[Hydro] data received:', data);
|
||||||
|
setData((prev) => (data.HYDRO_INJECTED ? data : { ...prev, ...data }));
|
||||||
|
history.pushState(null, '', url);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
|
setError(err);
|
||||||
|
console.error('[Hydro] navigation error:', err);
|
||||||
|
// Fall back to hard navigation on unexpected errors
|
||||||
|
window.location.href = url;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [setData, setLoading, setError]);
|
||||||
|
|
||||||
|
return navigate;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user