@nifrajs/web-react 1.6.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -0
- package/dist/client.js.map +1 -1
- package/dist/compose.d.ts +5 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +9 -1
- package/dist/compose.js.map +1 -1
- package/dist/query.d.ts +89 -6
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +147 -29
- package/dist/query.js.map +1 -1
- package/dist/router.d.ts +108 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +165 -0
- package/dist/router.js.map +1 -0
- package/package.json +10 -5
- package/src/client.ts +4 -0
- package/src/compose.ts +14 -1
- package/src/query.ts +234 -33
- package/src/router.ts +259 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web-react/router` — React routing bindings over the agnostic `@nifrajs/web` router:
|
|
3
|
+
* `<Link>`/`<NavLink>`, `useNavigate`, `useParams`, `useLocation`, `useSearchParams`, and `<Navigate>`.
|
|
4
|
+
*
|
|
5
|
+
* These read the current route from a {@link RouterContext} that `compose` provides on BOTH the SSR
|
|
6
|
+
* render and the client mount (seeded from the request match / router state respectively), so the read
|
|
7
|
+
* hooks (`useParams`/`useLocation`/`useSearchParams`) are SSR-correct and hydrate with no mismatch.
|
|
8
|
+
* Navigation goes through `@nifrajs/web`'s DOM-free navigate bridge (`getBrowserNavigate`, populated by
|
|
9
|
+
* `installHistory`) — so this module imports only `react` (never `react-dom/*`), and a route component
|
|
10
|
+
* can use these on the server and the client without dragging a DOM build into the wrong bundle. No JSX
|
|
11
|
+
* (the package builds with plain `tsc`), so everything is `createElement`.
|
|
12
|
+
*/
|
|
13
|
+
import { getBrowserNavigate, type NavigateOptions } from "@nifrajs/web"
|
|
14
|
+
import {
|
|
15
|
+
type AnchorHTMLAttributes,
|
|
16
|
+
type CSSProperties,
|
|
17
|
+
createContext,
|
|
18
|
+
createElement,
|
|
19
|
+
forwardRef,
|
|
20
|
+
type MouseEvent,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
useCallback,
|
|
23
|
+
useContext,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
} from "react"
|
|
27
|
+
|
|
28
|
+
/** The current route the routing hooks read. Provided by `compose` on SSR + client mount alike. */
|
|
29
|
+
export interface RouterContextValue {
|
|
30
|
+
/** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). */
|
|
31
|
+
readonly params: Readonly<Record<string, string>>
|
|
32
|
+
/** The current URL's `pathname + search` (no hash — the router never carries one). */
|
|
33
|
+
readonly path: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Frozen empty params so the default context value has a stable reference (no needless re-renders).
|
|
37
|
+
const EMPTY_PARAMS: Readonly<Record<string, string>> = Object.freeze({})
|
|
38
|
+
|
|
39
|
+
/** Router context. The default ({} params, "" path) is what a component sees when rendered outside a
|
|
40
|
+
* nifra route tree — the hooks stay defined (no throw) so a stray `useParams` degrades gracefully. */
|
|
41
|
+
export const RouterContext = createContext<RouterContextValue>({ params: EMPTY_PARAMS, path: "" })
|
|
42
|
+
|
|
43
|
+
/** Split a `pathname + search` into its parts. `search` keeps its leading `?` (like `location.search`);
|
|
44
|
+
* an empty query yields `""`. */
|
|
45
|
+
function splitPath(path: string): { readonly pathname: string; readonly search: string } {
|
|
46
|
+
const q = path.indexOf("?")
|
|
47
|
+
return q === -1
|
|
48
|
+
? { pathname: path, search: "" }
|
|
49
|
+
: { pathname: path.slice(0, q), search: path.slice(q) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The matched route's decoded path params — `/users/:id` on `/users/7` → `{ id: "7" }`. SSR-correct:
|
|
54
|
+
* `compose` provides the same value server-side (from the request match) and client-side (from router
|
|
55
|
+
* state), so a param rendered into markup doesn't flash on hydration.
|
|
56
|
+
*/
|
|
57
|
+
export function useParams<
|
|
58
|
+
T extends Record<string, string | undefined> = Record<string, string>,
|
|
59
|
+
>(): Readonly<T> {
|
|
60
|
+
return useContext(RouterContext).params as Readonly<T>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The parsed current location. `hash` is always `""` — the fragment is client-only and never reaches
|
|
64
|
+
* the router state / server, so exposing a live hash would hydration-mismatch; read `window.location.hash`
|
|
65
|
+
* directly (in an effect) if you truly need it. */
|
|
66
|
+
export interface Location {
|
|
67
|
+
readonly pathname: string
|
|
68
|
+
readonly search: string
|
|
69
|
+
readonly hash: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The current {@link Location} (`pathname`/`search`/`hash`), derived from the router context. */
|
|
73
|
+
export function useLocation(): Location {
|
|
74
|
+
const { path } = useContext(RouterContext)
|
|
75
|
+
return useMemo(() => {
|
|
76
|
+
const { pathname, search } = splitPath(path)
|
|
77
|
+
return { pathname, search, hash: "" }
|
|
78
|
+
}, [path])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A programmatic navigate: a string path (push, or replace via `{ replace: true }`) or a history delta
|
|
82
|
+
* (`-1`/`1`). A no-op on the server / before hydration (a render-time navigate isn't valid — use
|
|
83
|
+
* {@link Navigate}, which navigates in an effect). */
|
|
84
|
+
export type NavigateFunction = (to: string | number, options?: NavigateOptions) => void
|
|
85
|
+
|
|
86
|
+
/** Get the {@link NavigateFunction}. Stable across renders; resolves the browser navigate at call time
|
|
87
|
+
* (so it works as soon as `installHistory` has run, and no-ops before then / on the server). */
|
|
88
|
+
export function useNavigate(): NavigateFunction {
|
|
89
|
+
return useCallback((to, options) => {
|
|
90
|
+
const navigate = getBrowserNavigate()
|
|
91
|
+
if (navigate !== undefined) navigate(to, options)
|
|
92
|
+
}, [])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The value forms `setSearchParams` accepts. */
|
|
96
|
+
export type SearchParamsInit = URLSearchParams | Record<string, string | readonly string[]> | string
|
|
97
|
+
|
|
98
|
+
function toSearchParams(init: SearchParamsInit): URLSearchParams {
|
|
99
|
+
if (init instanceof URLSearchParams) return init
|
|
100
|
+
if (typeof init === "string") return new URLSearchParams(init)
|
|
101
|
+
const usp = new URLSearchParams()
|
|
102
|
+
for (const [key, value] of Object.entries(init)) {
|
|
103
|
+
if (Array.isArray(value)) for (const item of value) usp.append(key, item)
|
|
104
|
+
else usp.set(key, value as string)
|
|
105
|
+
}
|
|
106
|
+
return usp
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Set the query string. Accepts a `URLSearchParams`, a record, a raw string, or an updater of the
|
|
110
|
+
* current params; navigates to the same pathname with the new query (push, or replace via options). */
|
|
111
|
+
export type SetSearchParams = (
|
|
112
|
+
next: SearchParamsInit | ((prev: URLSearchParams) => SearchParamsInit),
|
|
113
|
+
options?: NavigateOptions,
|
|
114
|
+
) => void
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The current query as a `URLSearchParams` (SSR-correct via the router context) plus a setter that
|
|
118
|
+
* navigates to the new query. Mirrors react-router's `useSearchParams` tuple.
|
|
119
|
+
*/
|
|
120
|
+
export function useSearchParams(): readonly [URLSearchParams, SetSearchParams] {
|
|
121
|
+
const { path } = useContext(RouterContext)
|
|
122
|
+
const { pathname, search } = splitPath(path)
|
|
123
|
+
const searchParams = useMemo(() => new URLSearchParams(search), [search])
|
|
124
|
+
const navigate = useNavigate()
|
|
125
|
+
const setSearchParams = useCallback<SetSearchParams>(
|
|
126
|
+
(next, options) => {
|
|
127
|
+
const resolved = typeof next === "function" ? next(new URLSearchParams(search)) : next
|
|
128
|
+
const qs = toSearchParams(resolved).toString()
|
|
129
|
+
navigate(pathname + (qs !== "" ? `?${qs}` : ""), options)
|
|
130
|
+
},
|
|
131
|
+
[navigate, pathname, search],
|
|
132
|
+
)
|
|
133
|
+
return [searchParams, setSearchParams] as const
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A left-click with no modifier and no new-tab target — the only click a client router should
|
|
137
|
+
* intercept (Cmd/Ctrl/Shift/Alt or a `_blank` target means the user wants native behavior). */
|
|
138
|
+
function isPlainLeftClick(event: MouseEvent, target: string | undefined): boolean {
|
|
139
|
+
if (event.button !== 0) return false
|
|
140
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
|
|
141
|
+
return target === undefined || target === "" || target === "_self"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** {@link Link} props: every `<a>` attribute except `href` (set from `to`), plus `to` + `replace`. */
|
|
145
|
+
export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
|
146
|
+
/** Same-origin destination path (e.g. `/users/7?tab=posts`). Rendered as the `<a href>`. */
|
|
147
|
+
readonly to: string
|
|
148
|
+
/** Replace the current history entry instead of pushing. */
|
|
149
|
+
readonly replace?: boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A client-navigating anchor. Renders a real `<a href={to}>` (so it's a working link before hydration
|
|
154
|
+
* and for right-click / open-in-new-tab), and on a plain left-click navigates through the router
|
|
155
|
+
* instead of a full reload. Calling `navigate` + `preventDefault` here means `installHistory`'s
|
|
156
|
+
* document-level click handler sees `defaultPrevented` and stands down — exactly one navigation.
|
|
157
|
+
*/
|
|
158
|
+
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
|
|
159
|
+
{ to, replace, onClick, target, ...rest },
|
|
160
|
+
ref,
|
|
161
|
+
) {
|
|
162
|
+
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
|
163
|
+
onClick?.(event)
|
|
164
|
+
if (event.defaultPrevented) return // the app's own handler already took over
|
|
165
|
+
if (!isPlainLeftClick(event, target)) return // modified / new-tab click → native
|
|
166
|
+
const navigate = getBrowserNavigate()
|
|
167
|
+
if (navigate === undefined) return // pre-hydration → let the native <a href> full-load
|
|
168
|
+
event.preventDefault()
|
|
169
|
+
navigate(to, { replace: replace === true })
|
|
170
|
+
}
|
|
171
|
+
return createElement("a", { ...rest, href: to, target, onClick: handleClick, ref })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
/** The state a {@link NavLink}'s function-form `className`/`style`/`children` receive. */
|
|
175
|
+
export interface NavLinkRenderProps {
|
|
176
|
+
/** True when the current location matches this link's `to` (prefix match, or exact when `end`). */
|
|
177
|
+
readonly isActive: boolean
|
|
178
|
+
/** Reserved for a future pending-navigation signal; currently always `false`. */
|
|
179
|
+
readonly isPending: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** {@link NavLink} props — like {@link LinkProps}, but `className`/`style`/`children` may be functions
|
|
183
|
+
* of the active state, and `end`/`caseSensitive` tune matching. */
|
|
184
|
+
export interface NavLinkProps extends Omit<LinkProps, "className" | "style" | "children"> {
|
|
185
|
+
/** Match the full path exactly instead of as a prefix (use for `to="/"` so it isn't always active). */
|
|
186
|
+
readonly end?: boolean
|
|
187
|
+
/** Match case-sensitively (default: case-insensitive, like the DOM). */
|
|
188
|
+
readonly caseSensitive?: boolean
|
|
189
|
+
readonly className?: string | ((props: NavLinkRenderProps) => string | undefined)
|
|
190
|
+
readonly style?: CSSProperties | ((props: NavLinkRenderProps) => CSSProperties | undefined)
|
|
191
|
+
readonly children?: ReactNode | ((props: NavLinkRenderProps) => ReactNode)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Normalize a pathname for comparison: optional case-fold + drop a single trailing slash (but keep a
|
|
195
|
+
* bare `"/"`). */
|
|
196
|
+
function normalizePath(pathname: string, caseSensitive: boolean): string {
|
|
197
|
+
const cased = caseSensitive ? pathname : pathname.toLowerCase()
|
|
198
|
+
return cased !== "/" && cased.endsWith("/") ? cased.slice(0, -1) : cased
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A {@link Link} that knows whether it points at the current location. Adds `aria-current="page"` when
|
|
203
|
+
* active and resolves function-form `className`/`style`/`children` with `{ isActive, isPending }`.
|
|
204
|
+
* Default matching is prefix-on-segment-boundary (so `/users` is active on `/users/7`); pass `end` for
|
|
205
|
+
* an exact match.
|
|
206
|
+
*/
|
|
207
|
+
export const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>(function NavLink(
|
|
208
|
+
{ to, end, caseSensitive, className, style, children, ...rest },
|
|
209
|
+
ref,
|
|
210
|
+
) {
|
|
211
|
+
const { pathname } = useLocation()
|
|
212
|
+
const cs = caseSensitive === true
|
|
213
|
+
const current = normalizePath(pathname, cs)
|
|
214
|
+
const target = normalizePath(splitPath(to).pathname, cs)
|
|
215
|
+
const isActive = end === true ? current === target : matchesPrefix(current, target)
|
|
216
|
+
const renderProps: NavLinkRenderProps = { isActive, isPending: false }
|
|
217
|
+
const resolvedClassName = typeof className === "function" ? className(renderProps) : className
|
|
218
|
+
const resolvedStyle = typeof style === "function" ? style(renderProps) : style
|
|
219
|
+
const resolvedChildren = typeof children === "function" ? children(renderProps) : children
|
|
220
|
+
return createElement(
|
|
221
|
+
Link,
|
|
222
|
+
{
|
|
223
|
+
...rest,
|
|
224
|
+
to,
|
|
225
|
+
ref,
|
|
226
|
+
...(resolvedClassName !== undefined ? { className: resolvedClassName } : {}),
|
|
227
|
+
...(resolvedStyle !== undefined ? { style: resolvedStyle } : {}),
|
|
228
|
+
// `aria-current="page"` is the accessible active marker; style with `[aria-current="page"]`.
|
|
229
|
+
"aria-current": isActive ? "page" : undefined,
|
|
230
|
+
},
|
|
231
|
+
resolvedChildren,
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
/** Prefix match on a segment boundary: `current` is `target`, or lives under it (`target + "/"`…). A
|
|
236
|
+
* bare `"/"` target is a prefix of everything (so `<NavLink to="/">` is active everywhere unless `end`). */
|
|
237
|
+
function matchesPrefix(current: string, target: string): boolean {
|
|
238
|
+
if (current === target) return true
|
|
239
|
+
return current.startsWith(target === "/" ? "/" : `${target}/`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** {@link Navigate} props: the destination `to` and whether to `replace` the history entry. */
|
|
243
|
+
export interface NavigateProps {
|
|
244
|
+
readonly to: string
|
|
245
|
+
readonly replace?: boolean
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Declaratively navigate on mount — the component analogue of `useNavigate` (e.g. a guard that renders
|
|
250
|
+
* `<Navigate to="/login" replace />`). Navigates in an effect, so it's a safe no-op during SSR (renders
|
|
251
|
+
* `null`); the redirect happens once on the client after hydration.
|
|
252
|
+
*/
|
|
253
|
+
export function Navigate({ to, replace }: NavigateProps): null {
|
|
254
|
+
const navigate = useNavigate()
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
navigate(to, { replace: replace === true })
|
|
257
|
+
}, [navigate, to, replace])
|
|
258
|
+
return null
|
|
259
|
+
}
|