@fictjs/router 0.2.2 → 0.3.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/dist/index.cjs +2373 -3
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1136 -2
- package/dist/index.d.ts +1136 -2
- package/dist/index.js +2305 -3
- package/dist/index.js.map +1 -0
- package/package.json +33 -7
- package/src/components.tsx +926 -0
- package/src/context.ts +404 -0
- package/src/data.ts +545 -0
- package/src/history.ts +659 -0
- package/src/index.ts +217 -0
- package/src/lazy.tsx +242 -0
- package/src/link.tsx +601 -0
- package/src/scroll.ts +245 -0
- package/src/types.ts +447 -0
- package/src/utils.ts +570 -0
package/src/link.tsx
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Link components for @fictjs/router
|
|
3
|
+
*
|
|
4
|
+
* This module provides Link and NavLink components for declarative navigation.
|
|
5
|
+
* Integrates with Fict's reactive system for active state tracking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createMemo, type FictNode, type JSX, type StyleProp } from '@fictjs/runtime'
|
|
9
|
+
|
|
10
|
+
import { useRouter, useIsActive, useHref, usePendingLocation } from './context'
|
|
11
|
+
import type { To, NavigateOptions } from './types'
|
|
12
|
+
import { parseURL, stripBasePath } from './utils'
|
|
13
|
+
|
|
14
|
+
// CSS Properties type for styles
|
|
15
|
+
type CSSProperties = StyleProp
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Link Component
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface LinkProps extends Omit<JSX.IntrinsicElements['a'], 'href'> {
|
|
22
|
+
/** Navigation target */
|
|
23
|
+
to: To
|
|
24
|
+
/** Replace history entry instead of pushing */
|
|
25
|
+
replace?: boolean
|
|
26
|
+
/** State to pass with navigation */
|
|
27
|
+
state?: unknown
|
|
28
|
+
/** Scroll to top after navigation */
|
|
29
|
+
scroll?: boolean
|
|
30
|
+
/** Relative path resolution mode */
|
|
31
|
+
relative?: 'route' | 'path'
|
|
32
|
+
/** Force full page reload */
|
|
33
|
+
reloadDocument?: boolean
|
|
34
|
+
/** Preload behavior */
|
|
35
|
+
prefetch?: 'none' | 'intent' | 'render'
|
|
36
|
+
/** Prevent navigation (render as text) */
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
/** Custom click handler (called before navigation) */
|
|
39
|
+
onClick?: (event: MouseEvent) => void
|
|
40
|
+
children?: FictNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Link component for navigation
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <Link to="/about">About</Link>
|
|
49
|
+
* <Link to="/users/123" replace>View User</Link>
|
|
50
|
+
* <Link to={{ pathname: "/search", search: "?q=test" }}>Search</Link>
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function Link(props: LinkProps): FictNode {
|
|
54
|
+
const router = useRouter()
|
|
55
|
+
const href = useHref(() => props.to)
|
|
56
|
+
let preloadTriggered = false
|
|
57
|
+
|
|
58
|
+
const handleClick = (event: MouseEvent) => {
|
|
59
|
+
// Call custom onClick handler first
|
|
60
|
+
if (props.onClick) {
|
|
61
|
+
props.onClick(event)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Don't handle if default was prevented
|
|
65
|
+
if (event.defaultPrevented) return
|
|
66
|
+
|
|
67
|
+
// Don't handle modifier keys (open in new tab, etc.)
|
|
68
|
+
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
|
|
69
|
+
|
|
70
|
+
// Don't handle right-clicks
|
|
71
|
+
if (event.button !== 0) return
|
|
72
|
+
|
|
73
|
+
// Don't handle if reloadDocument is set
|
|
74
|
+
if (props.reloadDocument) return
|
|
75
|
+
|
|
76
|
+
// Don't handle if disabled
|
|
77
|
+
if (props.disabled) return
|
|
78
|
+
|
|
79
|
+
// Don't handle external links
|
|
80
|
+
const target = (event.currentTarget as HTMLAnchorElement).target
|
|
81
|
+
if (target && target !== '_self') return
|
|
82
|
+
|
|
83
|
+
// Prevent default browser navigation
|
|
84
|
+
event.preventDefault()
|
|
85
|
+
|
|
86
|
+
// Navigate using the router
|
|
87
|
+
const options: NavigateOptions = {
|
|
88
|
+
replace: props.replace,
|
|
89
|
+
state: props.state,
|
|
90
|
+
scroll: props.scroll,
|
|
91
|
+
relative: props.relative,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
router.navigate(props.to, options)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Preload handler for hover/focus
|
|
98
|
+
const triggerPreload = () => {
|
|
99
|
+
if (preloadTriggered || props.disabled || props.prefetch === 'none') return
|
|
100
|
+
preloadTriggered = true
|
|
101
|
+
|
|
102
|
+
// Emit a preload event that can be handled by route preloaders
|
|
103
|
+
const hrefValue = href()
|
|
104
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
105
|
+
window.dispatchEvent(
|
|
106
|
+
new CustomEvent('fict-router:preload', {
|
|
107
|
+
detail: { href: hrefValue, to: props.to },
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const handleMouseEnter = (event: MouseEvent) => {
|
|
114
|
+
if (props.prefetch === 'intent' || props.prefetch === undefined) {
|
|
115
|
+
triggerPreload()
|
|
116
|
+
}
|
|
117
|
+
// Call original handler if provided
|
|
118
|
+
const onMouseEnter = (props as any).onMouseEnter
|
|
119
|
+
if (onMouseEnter) onMouseEnter(event)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const handleFocus = (event: FocusEvent) => {
|
|
123
|
+
if (props.prefetch === 'intent' || props.prefetch === undefined) {
|
|
124
|
+
triggerPreload()
|
|
125
|
+
}
|
|
126
|
+
// Call original handler if provided
|
|
127
|
+
const onFocus = (props as any).onFocus
|
|
128
|
+
if (onFocus) onFocus(event)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Extract link-specific props, pass rest to anchor
|
|
132
|
+
const {
|
|
133
|
+
to: _to,
|
|
134
|
+
replace: _replace,
|
|
135
|
+
state: _state,
|
|
136
|
+
scroll: _scroll,
|
|
137
|
+
relative: _relative,
|
|
138
|
+
reloadDocument: _reloadDocument,
|
|
139
|
+
prefetch,
|
|
140
|
+
disabled,
|
|
141
|
+
onClick: _onClick,
|
|
142
|
+
children,
|
|
143
|
+
...anchorProps
|
|
144
|
+
} = props
|
|
145
|
+
|
|
146
|
+
if (disabled) {
|
|
147
|
+
// Render as span when disabled
|
|
148
|
+
return <span {...(anchorProps as any)}>{children}</span>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Trigger preload immediately if prefetch='render'
|
|
152
|
+
if (prefetch === 'render') {
|
|
153
|
+
triggerPreload()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<a
|
|
158
|
+
{...anchorProps}
|
|
159
|
+
href={href()}
|
|
160
|
+
onClick={handleClick}
|
|
161
|
+
onMouseEnter={handleMouseEnter}
|
|
162
|
+
onFocus={handleFocus}
|
|
163
|
+
>
|
|
164
|
+
{children}
|
|
165
|
+
</a>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// NavLink Component
|
|
171
|
+
// ============================================================================
|
|
172
|
+
|
|
173
|
+
export interface NavLinkRenderProps {
|
|
174
|
+
/** Whether the link is active */
|
|
175
|
+
isActive: boolean
|
|
176
|
+
/** Whether a navigation to this link is pending */
|
|
177
|
+
isPending: boolean
|
|
178
|
+
/** Whether a view transition is in progress */
|
|
179
|
+
isTransitioning: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface NavLinkProps extends Omit<LinkProps, 'className' | 'style' | 'children'> {
|
|
183
|
+
/** Class name - can be a function that receives render props */
|
|
184
|
+
className?: string | ((props: NavLinkRenderProps) => string | undefined)
|
|
185
|
+
/** Style - can be a function that receives render props */
|
|
186
|
+
style?: CSSProperties | ((props: NavLinkRenderProps) => CSSProperties | undefined)
|
|
187
|
+
/** Children - can be a function that receives render props */
|
|
188
|
+
children?: FictNode | ((props: NavLinkRenderProps) => FictNode)
|
|
189
|
+
/** Only match if path is exactly equal (not a prefix) */
|
|
190
|
+
end?: boolean
|
|
191
|
+
/** Case-sensitive matching */
|
|
192
|
+
caseSensitive?: boolean
|
|
193
|
+
/** Custom active class name */
|
|
194
|
+
activeClassName?: string
|
|
195
|
+
/** Custom pending class name */
|
|
196
|
+
pendingClassName?: string
|
|
197
|
+
/** Custom active style */
|
|
198
|
+
activeStyle?: CSSProperties
|
|
199
|
+
/** Custom pending style */
|
|
200
|
+
pendingStyle?: CSSProperties
|
|
201
|
+
/** aria-current value when active */
|
|
202
|
+
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* NavLink component for navigation with active state
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```tsx
|
|
210
|
+
* <NavLink to="/about" activeClassName="active">About</NavLink>
|
|
211
|
+
*
|
|
212
|
+
* <NavLink to="/users" end>
|
|
213
|
+
* {({ isActive }) => (
|
|
214
|
+
* <span className={isActive ? 'active' : ''}>Users</span>
|
|
215
|
+
* )}
|
|
216
|
+
* </NavLink>
|
|
217
|
+
*
|
|
218
|
+
* <NavLink
|
|
219
|
+
* to="/dashboard"
|
|
220
|
+
* className={({ isActive }) => isActive ? 'nav-active' : 'nav-link'}
|
|
221
|
+
* >
|
|
222
|
+
* Dashboard
|
|
223
|
+
* </NavLink>
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
export function NavLink(props: NavLinkProps): FictNode {
|
|
227
|
+
const router = useRouter()
|
|
228
|
+
const isActive = useIsActive(() => props.to, { end: props.end })
|
|
229
|
+
const href = useHref(() => props.to)
|
|
230
|
+
const pendingLocation = usePendingLocation()
|
|
231
|
+
|
|
232
|
+
// Compute isPending by comparing pending location with this link's target
|
|
233
|
+
const computeIsPending = (): boolean => {
|
|
234
|
+
const pending = pendingLocation()
|
|
235
|
+
if (!pending) return false
|
|
236
|
+
|
|
237
|
+
// Get the resolved path for this link
|
|
238
|
+
const resolvedHref = href()
|
|
239
|
+
const baseToStrip = router.base === '/' ? '' : router.base
|
|
240
|
+
|
|
241
|
+
// Strip base from pending location to compare
|
|
242
|
+
const pendingPathWithoutBase = stripBasePath(pending.pathname, baseToStrip)
|
|
243
|
+
|
|
244
|
+
// Parse the resolved href to get pathname
|
|
245
|
+
const parsed = parseURL(resolvedHref)
|
|
246
|
+
const targetPathWithoutBase = stripBasePath(parsed.pathname, baseToStrip)
|
|
247
|
+
|
|
248
|
+
// Check if the pending navigation is to this link's destination
|
|
249
|
+
if (props.end) {
|
|
250
|
+
return pendingPathWithoutBase === targetPathWithoutBase
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
pendingPathWithoutBase === targetPathWithoutBase ||
|
|
255
|
+
pendingPathWithoutBase.startsWith(targetPathWithoutBase + '/')
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Compute render props
|
|
260
|
+
const getRenderProps = (): NavLinkRenderProps => ({
|
|
261
|
+
isActive: isActive(),
|
|
262
|
+
isPending: computeIsPending(),
|
|
263
|
+
isTransitioning: router.isRouting(),
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// Compute className
|
|
267
|
+
const computedClassName = createMemo(() => {
|
|
268
|
+
const renderProps = getRenderProps()
|
|
269
|
+
const classes: string[] = []
|
|
270
|
+
|
|
271
|
+
// Base className
|
|
272
|
+
if (typeof props.className === 'function') {
|
|
273
|
+
const result = props.className(renderProps)
|
|
274
|
+
if (result) classes.push(result)
|
|
275
|
+
} else if (props.className) {
|
|
276
|
+
classes.push(props.className)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Active className
|
|
280
|
+
if (renderProps.isActive && props.activeClassName) {
|
|
281
|
+
classes.push(props.activeClassName)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Pending className
|
|
285
|
+
if (renderProps.isPending && props.pendingClassName) {
|
|
286
|
+
classes.push(props.pendingClassName)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return classes.join(' ') || undefined
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
// Compute style
|
|
293
|
+
const computedStyle = createMemo(() => {
|
|
294
|
+
const renderProps = getRenderProps()
|
|
295
|
+
const style: CSSProperties = {}
|
|
296
|
+
|
|
297
|
+
// Base style
|
|
298
|
+
if (typeof props.style === 'function') {
|
|
299
|
+
const result = props.style(renderProps)
|
|
300
|
+
if (result) Object.assign(style, result)
|
|
301
|
+
} else if (props.style) {
|
|
302
|
+
Object.assign(style, props.style)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Active style
|
|
306
|
+
if (renderProps.isActive && props.activeStyle) {
|
|
307
|
+
Object.assign(style, props.activeStyle)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Pending style
|
|
311
|
+
if (renderProps.isPending && props.pendingStyle) {
|
|
312
|
+
Object.assign(style, props.pendingStyle)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return Object.keys(style).length > 0 ? style : undefined
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Compute children
|
|
319
|
+
const computedChildren = createMemo(() => {
|
|
320
|
+
const renderProps = getRenderProps()
|
|
321
|
+
|
|
322
|
+
if (typeof props.children === 'function') {
|
|
323
|
+
return props.children(renderProps)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return props.children
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// Compute aria-current
|
|
330
|
+
const ariaCurrent = createMemo(() => {
|
|
331
|
+
const renderProps = getRenderProps()
|
|
332
|
+
if (!renderProps.isActive) return undefined
|
|
333
|
+
return props['aria-current'] || 'page'
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const handleClick = (event: MouseEvent) => {
|
|
337
|
+
// Call custom onClick handler first
|
|
338
|
+
if (props.onClick) {
|
|
339
|
+
props.onClick(event)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Don't handle if default was prevented
|
|
343
|
+
if (event.defaultPrevented) return
|
|
344
|
+
|
|
345
|
+
// Don't handle modifier keys
|
|
346
|
+
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
|
|
347
|
+
|
|
348
|
+
// Don't handle right-clicks
|
|
349
|
+
if (event.button !== 0) return
|
|
350
|
+
|
|
351
|
+
// Don't handle if reloadDocument is set
|
|
352
|
+
if (props.reloadDocument) return
|
|
353
|
+
|
|
354
|
+
// Don't handle if disabled
|
|
355
|
+
if (props.disabled) return
|
|
356
|
+
|
|
357
|
+
// Don't handle external links
|
|
358
|
+
const target = (event.currentTarget as HTMLAnchorElement).target
|
|
359
|
+
if (target && target !== '_self') return
|
|
360
|
+
|
|
361
|
+
// Prevent default browser navigation
|
|
362
|
+
event.preventDefault()
|
|
363
|
+
|
|
364
|
+
// Navigate using the router
|
|
365
|
+
router.navigate(props.to, {
|
|
366
|
+
replace: props.replace,
|
|
367
|
+
state: props.state,
|
|
368
|
+
scroll: props.scroll,
|
|
369
|
+
relative: props.relative,
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Extract NavLink-specific props
|
|
374
|
+
const {
|
|
375
|
+
to: _to,
|
|
376
|
+
replace: _replace,
|
|
377
|
+
state: _state,
|
|
378
|
+
scroll: _scroll,
|
|
379
|
+
relative: _relative,
|
|
380
|
+
reloadDocument: _reloadDocument,
|
|
381
|
+
prefetch: _prefetch,
|
|
382
|
+
disabled,
|
|
383
|
+
onClick: _onClick,
|
|
384
|
+
children: _children,
|
|
385
|
+
className: _className,
|
|
386
|
+
style: _style,
|
|
387
|
+
end: _end,
|
|
388
|
+
caseSensitive: _caseSensitive,
|
|
389
|
+
activeClassName: _activeClassName,
|
|
390
|
+
pendingClassName: _pendingClassName,
|
|
391
|
+
activeStyle: _activeStyle,
|
|
392
|
+
pendingStyle: _pendingStyle,
|
|
393
|
+
'aria-current': _ariaCurrent,
|
|
394
|
+
...anchorProps
|
|
395
|
+
} = props
|
|
396
|
+
|
|
397
|
+
if (disabled) {
|
|
398
|
+
return (
|
|
399
|
+
<span {...(anchorProps as any)} className={computedClassName()} style={computedStyle()}>
|
|
400
|
+
{computedChildren()}
|
|
401
|
+
</span>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const finalClassName = computedClassName()
|
|
406
|
+
const finalStyle = computedStyle()
|
|
407
|
+
const finalAriaCurrent = ariaCurrent()
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<a
|
|
411
|
+
{...anchorProps}
|
|
412
|
+
href={href()}
|
|
413
|
+
{...(finalClassName !== undefined ? { className: finalClassName } : {})}
|
|
414
|
+
{...(finalStyle !== undefined ? { style: finalStyle } : {})}
|
|
415
|
+
{...(finalAriaCurrent !== undefined ? { 'aria-current': finalAriaCurrent } : {})}
|
|
416
|
+
onClick={handleClick}
|
|
417
|
+
>
|
|
418
|
+
{computedChildren()}
|
|
419
|
+
</a>
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ============================================================================
|
|
424
|
+
// Form Component (for actions)
|
|
425
|
+
// ============================================================================
|
|
426
|
+
|
|
427
|
+
export interface FormProps extends Omit<JSX.IntrinsicElements['form'], 'action' | 'method'> {
|
|
428
|
+
/** Form action URL */
|
|
429
|
+
action?: string
|
|
430
|
+
/** HTTP method */
|
|
431
|
+
method?: 'get' | 'post' | 'put' | 'patch' | 'delete'
|
|
432
|
+
/** Replace history entry */
|
|
433
|
+
replace?: boolean
|
|
434
|
+
/** Relative path resolution */
|
|
435
|
+
relative?: 'route' | 'path'
|
|
436
|
+
/** Prevent navigation */
|
|
437
|
+
preventScrollReset?: boolean
|
|
438
|
+
/** Navigate on submit */
|
|
439
|
+
navigate?: boolean
|
|
440
|
+
/** Fetch mode */
|
|
441
|
+
fetcherKey?: string
|
|
442
|
+
children?: FictNode
|
|
443
|
+
onSubmit?: (event: SubmitEvent) => void
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Form component for action submissions
|
|
448
|
+
*
|
|
449
|
+
* @example
|
|
450
|
+
* ```tsx
|
|
451
|
+
* <Form action="/api/submit" method="post">
|
|
452
|
+
* <input name="email" type="email" />
|
|
453
|
+
* <button type="submit">Submit</button>
|
|
454
|
+
* </Form>
|
|
455
|
+
* ```
|
|
456
|
+
*/
|
|
457
|
+
export function Form(props: FormProps): FictNode {
|
|
458
|
+
const router = useRouter()
|
|
459
|
+
|
|
460
|
+
const handleSubmit = (event: SubmitEvent) => {
|
|
461
|
+
// Call custom onSubmit
|
|
462
|
+
if (props.onSubmit) {
|
|
463
|
+
props.onSubmit(event)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Don't handle if prevented
|
|
467
|
+
if (event.defaultPrevented) return
|
|
468
|
+
|
|
469
|
+
const form = event.currentTarget as HTMLFormElement
|
|
470
|
+
|
|
471
|
+
// Don't handle if form has a target that opens in a new window/frame
|
|
472
|
+
const target = form.target
|
|
473
|
+
if (target && target !== '_self') return
|
|
474
|
+
|
|
475
|
+
// Prevent default form submission
|
|
476
|
+
event.preventDefault()
|
|
477
|
+
|
|
478
|
+
const formData = new FormData(form)
|
|
479
|
+
const method = props.method?.toUpperCase() || 'GET'
|
|
480
|
+
|
|
481
|
+
const actionUrl = props.action || router.location().pathname
|
|
482
|
+
|
|
483
|
+
if (method === 'GET') {
|
|
484
|
+
// For GET, navigate with search params
|
|
485
|
+
const searchParams = new URLSearchParams()
|
|
486
|
+
formData.forEach((value, key) => {
|
|
487
|
+
if (typeof value === 'string') {
|
|
488
|
+
searchParams.append(key, value)
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
router.navigate(
|
|
493
|
+
{
|
|
494
|
+
pathname: actionUrl,
|
|
495
|
+
search: '?' + searchParams.toString(),
|
|
496
|
+
},
|
|
497
|
+
{ replace: props.replace },
|
|
498
|
+
)
|
|
499
|
+
} else {
|
|
500
|
+
// For POST/PUT/PATCH/DELETE, submit via fetch
|
|
501
|
+
submitFormAction(form, actionUrl, method, formData, {
|
|
502
|
+
navigate: props.navigate !== false,
|
|
503
|
+
replace: props.replace ?? false,
|
|
504
|
+
router,
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Submit form data via fetch for non-GET methods
|
|
511
|
+
*/
|
|
512
|
+
async function submitFormAction(
|
|
513
|
+
formElement: HTMLFormElement,
|
|
514
|
+
url: string,
|
|
515
|
+
method: string,
|
|
516
|
+
formData: FormData,
|
|
517
|
+
options: {
|
|
518
|
+
navigate: boolean
|
|
519
|
+
replace: boolean
|
|
520
|
+
router: typeof router
|
|
521
|
+
},
|
|
522
|
+
) {
|
|
523
|
+
try {
|
|
524
|
+
const response = await fetch(url, {
|
|
525
|
+
method,
|
|
526
|
+
body: formData,
|
|
527
|
+
headers: {
|
|
528
|
+
// Let the browser set Content-Type for FormData (includes boundary)
|
|
529
|
+
Accept: 'application/json',
|
|
530
|
+
},
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
if (!response.ok) {
|
|
534
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Try to parse JSON response
|
|
538
|
+
const contentType = response.headers.get('Content-Type')
|
|
539
|
+
let data: unknown = null
|
|
540
|
+
if (contentType?.includes('application/json')) {
|
|
541
|
+
data = await response.json()
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// If navigate is enabled and response includes a redirect location
|
|
545
|
+
const redirectUrl = response.headers.get('X-Redirect') || response.headers.get('Location')
|
|
546
|
+
if (options.navigate && redirectUrl) {
|
|
547
|
+
options.router.navigate(redirectUrl, { replace: options.replace })
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Emit a custom event for the form submission result on the actual form element
|
|
551
|
+
formElement.dispatchEvent(
|
|
552
|
+
new CustomEvent('formsubmit', {
|
|
553
|
+
bubbles: true,
|
|
554
|
+
detail: { data, response },
|
|
555
|
+
}),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
return { data, response }
|
|
559
|
+
} catch (error) {
|
|
560
|
+
// Emit error event on the actual form element
|
|
561
|
+
formElement.dispatchEvent(
|
|
562
|
+
new CustomEvent('formerror', {
|
|
563
|
+
bubbles: true,
|
|
564
|
+
detail: { error },
|
|
565
|
+
}),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
console.error('[fict-router] Form submission failed:', error)
|
|
569
|
+
throw error
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const {
|
|
574
|
+
action,
|
|
575
|
+
method,
|
|
576
|
+
replace: _replace,
|
|
577
|
+
relative: _relative,
|
|
578
|
+
preventScrollReset: _preventScrollReset,
|
|
579
|
+
navigate: _navigate,
|
|
580
|
+
fetcherKey: _fetcherKey,
|
|
581
|
+
children,
|
|
582
|
+
onSubmit: _onSubmit,
|
|
583
|
+
...formProps
|
|
584
|
+
} = props
|
|
585
|
+
|
|
586
|
+
// Only use standard form methods (get, post) for the HTML attribute
|
|
587
|
+
// Other methods (put, patch, delete) are handled via fetch in handleSubmit
|
|
588
|
+
const htmlMethod =
|
|
589
|
+
method && ['get', 'post'].includes(method) ? (method as 'get' | 'post') : undefined
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<form
|
|
593
|
+
{...formProps}
|
|
594
|
+
{...(action !== undefined ? { action } : {})}
|
|
595
|
+
{...(htmlMethod !== undefined ? { method: htmlMethod } : {})}
|
|
596
|
+
onSubmit={handleSubmit}
|
|
597
|
+
>
|
|
598
|
+
{children}
|
|
599
|
+
</form>
|
|
600
|
+
)
|
|
601
|
+
}
|