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