@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/scroll.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @fileoverview Scroll restoration utilities for @fictjs/router
3
+ *
4
+ * This module provides scroll position management including:
5
+ * - Saving scroll positions per location key
6
+ * - Restoring scroll on back/forward navigation
7
+ * - Scrolling to top on new navigation
8
+ * - Hash scrolling support (#section)
9
+ */
10
+
11
+ import type { Location } from './types'
12
+ import { isBrowser } from './utils'
13
+
14
+ // ============================================================================
15
+ // Scroll Position Storage
16
+ // ============================================================================
17
+
18
+ /** Stored scroll positions keyed by location key */
19
+ const scrollPositions = new Map<string, { x: number; y: number }>()
20
+
21
+ /** Maximum number of positions to store to prevent memory leaks */
22
+ const MAX_STORED_POSITIONS = 100
23
+
24
+ /**
25
+ * Save the current scroll position for a location
26
+ */
27
+ export function saveScrollPosition(key: string): void {
28
+ if (!isBrowser()) return
29
+
30
+ scrollPositions.set(key, {
31
+ x: window.scrollX,
32
+ y: window.scrollY,
33
+ })
34
+
35
+ // Evict oldest entries if we exceed the limit
36
+ if (scrollPositions.size > MAX_STORED_POSITIONS) {
37
+ const firstKey = scrollPositions.keys().next().value
38
+ if (firstKey) {
39
+ scrollPositions.delete(firstKey)
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Get the saved scroll position for a location
46
+ */
47
+ export function getSavedScrollPosition(key: string): { x: number; y: number } | undefined {
48
+ return scrollPositions.get(key)
49
+ }
50
+
51
+ /**
52
+ * Clear saved scroll position for a location
53
+ */
54
+ export function clearScrollPosition(key: string): void {
55
+ scrollPositions.delete(key)
56
+ }
57
+
58
+ /**
59
+ * Clear all saved scroll positions
60
+ */
61
+ export function clearAllScrollPositions(): void {
62
+ scrollPositions.clear()
63
+ }
64
+
65
+ // ============================================================================
66
+ // Scroll Actions
67
+ // ============================================================================
68
+
69
+ /**
70
+ * Scroll to a specific position
71
+ */
72
+ export function scrollTo(x: number, y: number, behavior: ScrollBehavior = 'auto'): void {
73
+ if (!isBrowser()) return
74
+
75
+ window.scrollTo({
76
+ left: x,
77
+ top: y,
78
+ behavior,
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Scroll to top of the page
84
+ */
85
+ export function scrollToTop(behavior: ScrollBehavior = 'auto'): void {
86
+ scrollTo(0, 0, behavior)
87
+ }
88
+
89
+ /**
90
+ * Scroll to an element by ID (hash navigation)
91
+ */
92
+ export function scrollToHash(hash: string, behavior: ScrollBehavior = 'auto'): boolean {
93
+ if (!isBrowser() || !hash) return false
94
+
95
+ // Remove the leading #
96
+ const id = hash.startsWith('#') ? hash.slice(1) : hash
97
+ if (!id) return false
98
+
99
+ const element = document.getElementById(id)
100
+ if (element) {
101
+ element.scrollIntoView({ behavior })
102
+ return true
103
+ }
104
+
105
+ return false
106
+ }
107
+
108
+ /**
109
+ * Restore scroll position for a location
110
+ * Returns true if position was restored
111
+ */
112
+ export function restoreScrollPosition(key: string): boolean {
113
+ if (!isBrowser()) return false
114
+
115
+ const position = scrollPositions.get(key)
116
+ if (position) {
117
+ // Use requestAnimationFrame to ensure DOM has updated
118
+ requestAnimationFrame(() => {
119
+ scrollTo(position.x, position.y)
120
+ })
121
+ return true
122
+ }
123
+
124
+ return false
125
+ }
126
+
127
+ // ============================================================================
128
+ // Scroll Restoration Manager
129
+ // ============================================================================
130
+
131
+ export interface ScrollRestorationOptions {
132
+ /** Whether scroll restoration is enabled */
133
+ enabled?: boolean
134
+ /** Whether to restore scroll on back/forward navigation */
135
+ restoreOnPop?: boolean
136
+ /** Whether to scroll to top on push navigation */
137
+ scrollToTopOnPush?: boolean
138
+ /** Default scroll behavior */
139
+ behavior?: ScrollBehavior
140
+ }
141
+
142
+ const defaultOptions: Required<ScrollRestorationOptions> = {
143
+ enabled: true,
144
+ restoreOnPop: true,
145
+ scrollToTopOnPush: true,
146
+ behavior: 'auto',
147
+ }
148
+
149
+ /**
150
+ * Create a scroll restoration manager
151
+ */
152
+ export function createScrollRestoration(options: ScrollRestorationOptions = {}) {
153
+ const config = { ...defaultOptions, ...options }
154
+
155
+ // Disable browser's native scroll restoration
156
+ if (isBrowser() && 'scrollRestoration' in history) {
157
+ history.scrollRestoration = 'manual'
158
+ }
159
+
160
+ /**
161
+ * Handle navigation to save/restore scroll
162
+ */
163
+ function handleNavigation(
164
+ from: Location | null,
165
+ to: Location,
166
+ action: 'PUSH' | 'REPLACE' | 'POP',
167
+ ): void {
168
+ if (!config.enabled || !isBrowser()) return
169
+
170
+ // Save current position before navigating
171
+ if (from?.key) {
172
+ saveScrollPosition(from.key)
173
+ }
174
+
175
+ // Determine what scroll action to take
176
+ if (action === 'POP' && config.restoreOnPop) {
177
+ // Back/forward navigation - try to restore position
178
+ if (!restoreScrollPosition(to.key)) {
179
+ // No saved position, handle hash or scroll to top
180
+ if (to.hash) {
181
+ requestAnimationFrame(() => {
182
+ if (!scrollToHash(to.hash, config.behavior)) {
183
+ scrollToTop(config.behavior)
184
+ }
185
+ })
186
+ } else {
187
+ scrollToTop(config.behavior)
188
+ }
189
+ }
190
+ } else if ((action === 'PUSH' || action === 'REPLACE') && config.scrollToTopOnPush) {
191
+ // New navigation - handle hash or scroll to top
192
+ requestAnimationFrame(() => {
193
+ if (to.hash) {
194
+ if (!scrollToHash(to.hash, config.behavior)) {
195
+ scrollToTop(config.behavior)
196
+ }
197
+ } else {
198
+ scrollToTop(config.behavior)
199
+ }
200
+ })
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Reset scroll restoration to browser defaults
206
+ */
207
+ function reset(): void {
208
+ if (isBrowser() && 'scrollRestoration' in history) {
209
+ history.scrollRestoration = 'auto'
210
+ }
211
+ clearAllScrollPositions()
212
+ }
213
+
214
+ return {
215
+ handleNavigation,
216
+ saveScrollPosition,
217
+ restoreScrollPosition,
218
+ scrollToTop: () => scrollToTop(config.behavior),
219
+ scrollToHash: (hash: string) => scrollToHash(hash, config.behavior),
220
+ reset,
221
+ config,
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Default scroll restoration instance
227
+ */
228
+ let defaultScrollRestoration: ReturnType<typeof createScrollRestoration> | null = null
229
+
230
+ /**
231
+ * Get or create the default scroll restoration instance
232
+ */
233
+ export function getScrollRestoration(): ReturnType<typeof createScrollRestoration> {
234
+ if (!defaultScrollRestoration) {
235
+ defaultScrollRestoration = createScrollRestoration()
236
+ }
237
+ return defaultScrollRestoration
238
+ }
239
+
240
+ /**
241
+ * Configure the default scroll restoration
242
+ */
243
+ export function configureScrollRestoration(options: ScrollRestorationOptions): void {
244
+ defaultScrollRestoration = createScrollRestoration(options)
245
+ }
package/src/types.ts ADDED
@@ -0,0 +1,447 @@
1
+ /**
2
+ * @fileoverview Core type definitions for @fictjs/router
3
+ *
4
+ * This module defines the fundamental types used throughout the router.
5
+ * Designed to integrate seamlessly with Fict's reactive system.
6
+ */
7
+
8
+ import type { FictNode, Component } from '@fictjs/runtime'
9
+
10
+ // ============================================================================
11
+ // Location Types
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Represents a location in the router.
16
+ * Similar to window.location but with reactive support.
17
+ */
18
+ export interface Location {
19
+ /** The pathname portion of the URL (e.g., "/users/123") */
20
+ pathname: string
21
+ /** The search/query portion of the URL (e.g., "?page=1") */
22
+ search: string
23
+ /** The hash portion of the URL (e.g., "#section") */
24
+ hash: string
25
+ /** State associated with this location */
26
+ state: unknown
27
+ /** Unique key for this location entry */
28
+ key: string
29
+ }
30
+
31
+ /**
32
+ * Target for navigation - can be a string path or a partial location object
33
+ */
34
+ export type To = string | Partial<Location>
35
+
36
+ /**
37
+ * Navigation intent type
38
+ */
39
+ export type NavigationIntent = 'initial' | 'navigate' | 'native' | 'preload'
40
+
41
+ // ============================================================================
42
+ // Parameter Types
43
+ // ============================================================================
44
+
45
+ /**
46
+ * Route parameters extracted from the URL
47
+ */
48
+ export type Params<Key extends string = string> = Readonly<Record<Key, string | undefined>>
49
+
50
+ /**
51
+ * Search parameters from the query string
52
+ */
53
+ export type SearchParams = URLSearchParams
54
+
55
+ /**
56
+ * Match filter for validating route parameters
57
+ */
58
+ export type MatchFilter<T = string> = RegExp | readonly T[] | ((value: string) => boolean)
59
+
60
+ /**
61
+ * Match filters for route parameters
62
+ */
63
+ export type MatchFilters<P extends string = string> = Partial<Record<P, MatchFilter>>
64
+
65
+ // ============================================================================
66
+ // Route Definition Types
67
+ // ============================================================================
68
+
69
+ /**
70
+ * Props passed to route components
71
+ */
72
+ export interface RouteComponentProps<P extends string = string> {
73
+ /** Route parameters */
74
+ params: Params<P>
75
+ /** Current location */
76
+ location: Location
77
+ /** Preloaded data (if preload function is defined) */
78
+ data?: unknown
79
+ /** Children routes rendered via <Outlet /> */
80
+ children?: FictNode
81
+ /** Allow additional properties for component extensibility */
82
+ [key: string]: unknown
83
+ }
84
+
85
+ /**
86
+ * Preload function arguments
87
+ */
88
+ export interface PreloadArgs<P extends string = string> {
89
+ /** Route parameters */
90
+ params: Params<P>
91
+ /** Current location */
92
+ location: Location
93
+ /** Navigation intent */
94
+ intent: NavigationIntent
95
+ }
96
+
97
+ /**
98
+ * Preload function type
99
+ */
100
+ export type PreloadFunction<T = unknown, P extends string = string> = (
101
+ args: PreloadArgs<P>,
102
+ ) => T | Promise<T>
103
+
104
+ /**
105
+ * Route definition - user-facing configuration
106
+ */
107
+ export interface RouteDefinition<P extends string = string> {
108
+ /** Path pattern (e.g., "/users/:id", "/items/:id?") */
109
+ path?: string
110
+ /** Component to render for this route */
111
+ component?: Component<RouteComponentProps<P>>
112
+ /** Element to render (alternative to component) */
113
+ element?: FictNode
114
+ /** Preload function for data loading */
115
+ preload?: PreloadFunction<unknown, P>
116
+ /** Nested child routes */
117
+ children?: RouteDefinition[]
118
+ /** Parameter validation filters */
119
+ matchFilters?: MatchFilters<P>
120
+ /** Whether this is an index route */
121
+ index?: boolean
122
+ /** Route key for caching/optimization */
123
+ key?: string
124
+ /** Catch-all error boundary element */
125
+ errorElement?: FictNode
126
+ /** Loading fallback element */
127
+ loadingElement?: FictNode
128
+ }
129
+
130
+ /**
131
+ * Props for the Route component (JSX-based definition)
132
+ */
133
+ export interface RouteProps<P extends string = string> extends Omit<
134
+ RouteDefinition<P>,
135
+ 'children'
136
+ > {
137
+ /** JSX children (nested Route components) */
138
+ children?: FictNode
139
+ }
140
+
141
+ // ============================================================================
142
+ // Match Types
143
+ // ============================================================================
144
+
145
+ /**
146
+ * Result of matching a route against a location
147
+ */
148
+ export interface RouteMatch<P extends string = string> {
149
+ /** The matched route definition */
150
+ route: RouteDefinition<P>
151
+ /** The matched portion of the pathname */
152
+ pathname: string
153
+ /** Extracted parameters */
154
+ params: Params<P>
155
+ /** The pattern that matched */
156
+ pattern: string
157
+ }
158
+
159
+ /**
160
+ * Internal compiled route with matcher function
161
+ */
162
+ export interface CompiledRoute {
163
+ /** Original route definition */
164
+ route: RouteDefinition
165
+ /** Normalized path pattern */
166
+ pattern: string
167
+ /** Matcher function */
168
+ matcher: (pathname: string) => RouteMatch | null
169
+ /** Route score for ranking */
170
+ score: number
171
+ /** Child compiled routes */
172
+ children?: CompiledRoute[]
173
+ /** Unique key for this route */
174
+ key: string
175
+ }
176
+
177
+ /**
178
+ * Branch of routes (for nested route matching)
179
+ */
180
+ export interface RouteBranch {
181
+ /** Routes in this branch from root to leaf */
182
+ routes: CompiledRoute[]
183
+ /** Combined score for the branch */
184
+ score: number
185
+ /** Matcher for the entire branch */
186
+ matcher: (pathname: string) => RouteMatch[] | null
187
+ }
188
+
189
+ // ============================================================================
190
+ // Navigation Types
191
+ // ============================================================================
192
+
193
+ /**
194
+ * Options for navigation
195
+ */
196
+ export interface NavigateOptions {
197
+ /** Replace current history entry instead of pushing */
198
+ replace?: boolean | undefined
199
+ /** State to pass with the navigation */
200
+ state?: unknown
201
+ /** Scroll to top after navigation */
202
+ scroll?: boolean | undefined
203
+ /** Resolve path relative to current route */
204
+ relative?: 'route' | 'path' | undefined
205
+ }
206
+
207
+ /**
208
+ * Navigation function type
209
+ */
210
+ export interface NavigateFunction {
211
+ (to: To, options?: NavigateOptions): void
212
+ (delta: number): void
213
+ }
214
+
215
+ /**
216
+ * Navigation state during transitions
217
+ */
218
+ export interface Navigation {
219
+ /** Current navigation state */
220
+ state: 'idle' | 'loading' | 'submitting'
221
+ /** Target location (if loading) */
222
+ location?: Location
223
+ /** Form data (if submitting) */
224
+ formData?: FormData
225
+ /** Form action (if submitting) */
226
+ formAction?: string
227
+ /** Form method (if submitting) */
228
+ formMethod?: string
229
+ }
230
+
231
+ // ============================================================================
232
+ // Context Types
233
+ // ============================================================================
234
+
235
+ /**
236
+ * Router context value
237
+ */
238
+ export interface RouterContextValue {
239
+ /** Current location (reactive) */
240
+ location: () => Location
241
+ /** Route parameters (reactive) */
242
+ params: () => Params
243
+ /** Current matches (reactive) */
244
+ matches: () => RouteMatch[]
245
+ /** Navigate function */
246
+ navigate: NavigateFunction
247
+ /** Whether currently routing */
248
+ isRouting: () => boolean
249
+ /** Pending navigation target (if routing) */
250
+ pendingLocation: () => Location | null
251
+ /** Base path for the router */
252
+ base: string
253
+ /** Resolve a path relative to the current route */
254
+ resolvePath: (to: To) => string
255
+ }
256
+
257
+ /**
258
+ * Route context value (for nested routes)
259
+ */
260
+ export interface RouteContextValue {
261
+ /** The current route match */
262
+ match: () => RouteMatch | undefined
263
+ /** Preloaded data */
264
+ data: () => unknown
265
+ /** Route error (if any) */
266
+ error?: () => unknown
267
+ /** Outlet function to render child route */
268
+ outlet: () => FictNode
269
+ /** Parent route context */
270
+ parent?: RouteContextValue
271
+ /** Resolve path relative to this route */
272
+ resolvePath: (to: To) => string
273
+ }
274
+
275
+ // ============================================================================
276
+ // History Types
277
+ // ============================================================================
278
+
279
+ /**
280
+ * History action type
281
+ */
282
+ export type HistoryAction = 'POP' | 'PUSH' | 'REPLACE'
283
+
284
+ /**
285
+ * History listener callback
286
+ */
287
+ export type HistoryListener = (update: { action: HistoryAction; location: Location }) => void
288
+
289
+ /**
290
+ * History interface (browser, hash, or memory)
291
+ */
292
+ export interface History {
293
+ /** Current action */
294
+ readonly action: HistoryAction
295
+ /** Current location */
296
+ readonly location: Location
297
+ /** Push a new entry */
298
+ push(to: To, state?: unknown): void
299
+ /** Replace the current entry */
300
+ replace(to: To, state?: unknown): void
301
+ /** Go forward or backward */
302
+ go(delta: number): void
303
+ /** Go back one entry */
304
+ back(): void
305
+ /** Go forward one entry */
306
+ forward(): void
307
+ /** Listen for location changes */
308
+ listen(listener: HistoryListener): () => void
309
+ /** Create an href from a To value */
310
+ createHref(to: To): string
311
+ /** Block navigation */
312
+ block(blocker: Blocker): () => void
313
+ }
314
+
315
+ /**
316
+ * Blocker function for preventing navigation
317
+ */
318
+ export type Blocker = (tx: { action: HistoryAction; location: Location; retry: () => void }) => void
319
+
320
+ // ============================================================================
321
+ // BeforeLeave Types
322
+ // ============================================================================
323
+
324
+ /**
325
+ * Arguments passed to beforeLeave handlers
326
+ */
327
+ export interface BeforeLeaveEventArgs {
328
+ /** Target location */
329
+ to: Location
330
+ /** Current location */
331
+ from: Location
332
+ /** Whether this was prevented */
333
+ defaultPrevented: boolean
334
+ /** Prevent the navigation */
335
+ preventDefault: () => void
336
+ /** Retry the navigation */
337
+ retry: (force?: boolean) => void
338
+ }
339
+
340
+ /**
341
+ * BeforeLeave handler function
342
+ */
343
+ export type BeforeLeaveHandler = (e: BeforeLeaveEventArgs) => void | Promise<void>
344
+
345
+ // ============================================================================
346
+ // Data Loading Types
347
+ // ============================================================================
348
+
349
+ /**
350
+ * Submission state
351
+ */
352
+ export interface Submission<T = unknown> {
353
+ /** Unique submission key */
354
+ key: string
355
+ /** Form data being submitted */
356
+ formData: FormData
357
+ /** Submission state */
358
+ state: 'submitting' | 'loading' | 'idle'
359
+ /** Result data */
360
+ result?: T
361
+ /** Error if submission failed */
362
+ error?: unknown
363
+ /** Clear the submission */
364
+ clear: () => void
365
+ /** Retry the submission */
366
+ retry: () => void
367
+ }
368
+
369
+ /**
370
+ * Action function type
371
+ */
372
+ export type ActionFunction<T = unknown> = (
373
+ formData: FormData,
374
+ args: { params: Params; request: Request },
375
+ ) => T | Promise<T>
376
+
377
+ /**
378
+ * Action object returned by createAction
379
+ */
380
+ export interface Action<T = unknown> {
381
+ /** Action URL */
382
+ url: string
383
+ /** Submit the action */
384
+ submit: (formData: FormData) => Promise<T>
385
+ /** Action name */
386
+ name?: string
387
+ }
388
+
389
+ /**
390
+ * Query function type
391
+ */
392
+ export type QueryFunction<T = unknown, Args extends unknown[] = unknown[]> = (
393
+ ...args: Args
394
+ ) => T | Promise<T>
395
+
396
+ /**
397
+ * Query cache entry
398
+ */
399
+ export interface QueryCacheEntry<T = unknown> {
400
+ /** Timestamp when cached */
401
+ timestamp: number
402
+ /** Cached promise */
403
+ promise: Promise<T>
404
+ /** Resolved result */
405
+ result?: T
406
+ /** Intent when fetched */
407
+ intent: NavigationIntent
408
+ }
409
+
410
+ // ============================================================================
411
+ // Router Configuration Types
412
+ // ============================================================================
413
+
414
+ /**
415
+ * Router configuration options
416
+ */
417
+ export interface RouterOptions {
418
+ /** Base path for the router */
419
+ base?: string
420
+ /** Initial location (for SSR) */
421
+ url?: string
422
+ /** History implementation to use */
423
+ history?: History
424
+ /** Data preloaded on server */
425
+ hydrationData?: {
426
+ loaderData?: Record<string, unknown>
427
+ actionData?: Record<string, unknown>
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Memory router options
433
+ */
434
+ export interface MemoryRouterOptions extends RouterOptions {
435
+ /** Initial entries in the history stack */
436
+ initialEntries?: string[]
437
+ /** Initial index in the history stack */
438
+ initialIndex?: number
439
+ }
440
+
441
+ /**
442
+ * Hash router options
443
+ */
444
+ export interface HashRouterOptions extends RouterOptions {
445
+ /** Hash type: "slash" for /#/path, "noslash" for /#path */
446
+ hashType?: 'slash' | 'noslash'
447
+ }