@nifrajs/web-react 1.7.0 → 1.10.0

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/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
+ }