@flexireact/core 2.0.1 → 2.2.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.
@@ -0,0 +1,345 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * FlexiReact Link Component
5
+ * Enhanced link with prefetching, client-side navigation, and loading states
6
+ */
7
+
8
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
9
+
10
+ export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
11
+ /** The URL to navigate to */
12
+ href: string;
13
+ /** Prefetch the page on hover/visibility */
14
+ prefetch?: boolean | 'hover' | 'viewport';
15
+ /** Replace the current history entry instead of pushing */
16
+ replace?: boolean;
17
+ /** Scroll to top after navigation */
18
+ scroll?: boolean;
19
+ /** Show loading indicator while navigating */
20
+ showLoading?: boolean;
21
+ /** Custom loading component */
22
+ loadingComponent?: React.ReactNode;
23
+ /** Callback when navigation starts */
24
+ onNavigationStart?: () => void;
25
+ /** Callback when navigation ends */
26
+ onNavigationEnd?: () => void;
27
+ /** Children */
28
+ children: React.ReactNode;
29
+ }
30
+
31
+ // Prefetch cache to avoid duplicate requests
32
+ const prefetchCache = new Set<string>();
33
+
34
+ // Prefetch a URL
35
+ async function prefetchUrl(url: string): Promise<void> {
36
+ if (prefetchCache.has(url)) return;
37
+
38
+ try {
39
+ // Mark as prefetched immediately to prevent duplicate requests
40
+ prefetchCache.add(url);
41
+
42
+ // Use link preload for better browser optimization
43
+ const link = document.createElement('link');
44
+ link.rel = 'prefetch';
45
+ link.href = url;
46
+ link.as = 'document';
47
+ document.head.appendChild(link);
48
+
49
+ // Also fetch the page to warm the cache
50
+ const controller = new AbortController();
51
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
52
+
53
+ await fetch(url, {
54
+ method: 'GET',
55
+ credentials: 'same-origin',
56
+ signal: controller.signal,
57
+ headers: {
58
+ 'X-Flexi-Prefetch': '1',
59
+ 'Accept': 'text/html'
60
+ }
61
+ });
62
+
63
+ clearTimeout(timeoutId);
64
+ } catch (error) {
65
+ // Remove from cache on error so it can be retried
66
+ prefetchCache.delete(url);
67
+ }
68
+ }
69
+
70
+ // Check if URL is internal
71
+ function isInternalUrl(url: string): boolean {
72
+ if (url.startsWith('/')) return true;
73
+ if (url.startsWith('#')) return true;
74
+
75
+ try {
76
+ const parsed = new URL(url, window.location.origin);
77
+ return parsed.origin === window.location.origin;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // Navigate to a URL
84
+ function navigate(url: string, options: { replace?: boolean; scroll?: boolean } = {}): void {
85
+ const { replace = false, scroll = true } = options;
86
+
87
+ if (replace) {
88
+ window.history.replaceState({}, '', url);
89
+ } else {
90
+ window.history.pushState({}, '', url);
91
+ }
92
+
93
+ // Dispatch popstate event to trigger any listeners
94
+ window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
95
+
96
+ // Scroll to top if requested
97
+ if (scroll) {
98
+ window.scrollTo({ top: 0, behavior: 'smooth' });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Link component with prefetching and client-side navigation
104
+ *
105
+ * @example
106
+ * ```tsx
107
+ * import { Link } from '@flexireact/core/client';
108
+ *
109
+ * // Basic usage
110
+ * <Link href="/about">About</Link>
111
+ *
112
+ * // With prefetch on hover
113
+ * <Link href="/products" prefetch="hover">Products</Link>
114
+ *
115
+ * // With prefetch on viewport visibility
116
+ * <Link href="/contact" prefetch="viewport">Contact</Link>
117
+ *
118
+ * // Replace history instead of push
119
+ * <Link href="/login" replace>Login</Link>
120
+ *
121
+ * // Disable scroll to top
122
+ * <Link href="/section#anchor" scroll={false}>Go to section</Link>
123
+ * ```
124
+ */
125
+ export function Link({
126
+ href,
127
+ prefetch = true,
128
+ replace = false,
129
+ scroll = true,
130
+ showLoading = false,
131
+ loadingComponent,
132
+ onNavigationStart,
133
+ onNavigationEnd,
134
+ children,
135
+ className,
136
+ onClick,
137
+ onMouseEnter,
138
+ onFocus,
139
+ ...props
140
+ }: LinkProps) {
141
+ const [isNavigating, setIsNavigating] = useState(false);
142
+ const linkRef = useRef<HTMLAnchorElement>(null);
143
+ const hasPrefetched = useRef(false);
144
+
145
+ // Prefetch on viewport visibility
146
+ useEffect(() => {
147
+ if (prefetch !== 'viewport' && prefetch !== true) return;
148
+ if (!isInternalUrl(href)) return;
149
+ if (hasPrefetched.current) return;
150
+
151
+ const observer = new IntersectionObserver(
152
+ (entries) => {
153
+ entries.forEach((entry) => {
154
+ if (entry.isIntersecting) {
155
+ prefetchUrl(href);
156
+ hasPrefetched.current = true;
157
+ observer.disconnect();
158
+ }
159
+ });
160
+ },
161
+ { rootMargin: '200px' }
162
+ );
163
+
164
+ if (linkRef.current) {
165
+ observer.observe(linkRef.current);
166
+ }
167
+
168
+ return () => observer.disconnect();
169
+ }, [href, prefetch]);
170
+
171
+ // Handle hover prefetch
172
+ const handleMouseEnter = useCallback(
173
+ (e: React.MouseEvent<HTMLAnchorElement>) => {
174
+ onMouseEnter?.(e);
175
+
176
+ if ((prefetch === 'hover' || prefetch === true) && isInternalUrl(href)) {
177
+ prefetchUrl(href);
178
+ }
179
+ },
180
+ [href, prefetch, onMouseEnter]
181
+ );
182
+
183
+ // Handle focus prefetch (for keyboard navigation)
184
+ const handleFocus = useCallback(
185
+ (e: React.FocusEvent<HTMLAnchorElement>) => {
186
+ onFocus?.(e);
187
+
188
+ if ((prefetch === 'hover' || prefetch === true) && isInternalUrl(href)) {
189
+ prefetchUrl(href);
190
+ }
191
+ },
192
+ [href, prefetch, onFocus]
193
+ );
194
+
195
+ // Handle click for client-side navigation
196
+ const handleClick = useCallback(
197
+ async (e: React.MouseEvent<HTMLAnchorElement>) => {
198
+ onClick?.(e);
199
+
200
+ // Don't handle if default was prevented
201
+ if (e.defaultPrevented) return;
202
+
203
+ // Don't handle if modifier keys are pressed (open in new tab, etc.)
204
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
205
+
206
+ // Don't handle external URLs
207
+ if (!isInternalUrl(href)) return;
208
+
209
+ // Don't handle if target is set
210
+ if (props.target && props.target !== '_self') return;
211
+
212
+ // Prevent default navigation
213
+ e.preventDefault();
214
+
215
+ // Start navigation
216
+ setIsNavigating(true);
217
+ onNavigationStart?.();
218
+
219
+ try {
220
+ // Fetch the new page
221
+ const response = await fetch(href, {
222
+ method: 'GET',
223
+ credentials: 'same-origin',
224
+ headers: {
225
+ 'X-Flexi-Navigation': '1',
226
+ 'Accept': 'text/html'
227
+ }
228
+ });
229
+
230
+ if (response.ok) {
231
+ const html = await response.text();
232
+
233
+ // Parse and update the page
234
+ const parser = new DOMParser();
235
+ const doc = parser.parseFromString(html, 'text/html');
236
+
237
+ // Update title
238
+ const newTitle = doc.querySelector('title')?.textContent;
239
+ if (newTitle) {
240
+ document.title = newTitle;
241
+ }
242
+
243
+ // Update body content (or specific container)
244
+ const newContent = doc.querySelector('#root') || doc.body;
245
+ const currentContent = document.querySelector('#root') || document.body;
246
+
247
+ if (newContent && currentContent) {
248
+ currentContent.innerHTML = newContent.innerHTML;
249
+ }
250
+
251
+ // Update URL
252
+ navigate(href, { replace, scroll });
253
+ } else {
254
+ // Fallback to regular navigation on error
255
+ window.location.href = href;
256
+ }
257
+ } catch (error) {
258
+ // Fallback to regular navigation on error
259
+ window.location.href = href;
260
+ } finally {
261
+ setIsNavigating(false);
262
+ onNavigationEnd?.();
263
+ }
264
+ },
265
+ [href, replace, scroll, onClick, onNavigationStart, onNavigationEnd, props.target]
266
+ );
267
+
268
+ return (
269
+ <a
270
+ ref={linkRef}
271
+ href={href}
272
+ className={className}
273
+ onClick={handleClick}
274
+ onMouseEnter={handleMouseEnter}
275
+ onFocus={handleFocus}
276
+ data-prefetch={prefetch}
277
+ data-navigating={isNavigating || undefined}
278
+ {...props}
279
+ >
280
+ {showLoading && isNavigating ? (
281
+ loadingComponent || (
282
+ <span className="flexi-link-loading">
283
+ <span className="flexi-link-spinner" />
284
+ {children}
285
+ </span>
286
+ )
287
+ ) : (
288
+ children
289
+ )}
290
+ </a>
291
+ );
292
+ }
293
+
294
+ /**
295
+ * Programmatic navigation function
296
+ *
297
+ * @example
298
+ * ```tsx
299
+ * import { useRouter } from '@flexireact/core/client';
300
+ *
301
+ * function MyComponent() {
302
+ * const router = useRouter();
303
+ *
304
+ * const handleClick = () => {
305
+ * router.push('/dashboard');
306
+ * };
307
+ *
308
+ * return <button onClick={handleClick}>Go to Dashboard</button>;
309
+ * }
310
+ * ```
311
+ */
312
+ export function useRouter() {
313
+ return {
314
+ push(url: string, options?: { scroll?: boolean }) {
315
+ navigate(url, { replace: false, scroll: options?.scroll ?? true });
316
+ // Trigger page reload for now (full SPA navigation requires more work)
317
+ window.location.href = url;
318
+ },
319
+
320
+ replace(url: string, options?: { scroll?: boolean }) {
321
+ navigate(url, { replace: true, scroll: options?.scroll ?? true });
322
+ window.location.href = url;
323
+ },
324
+
325
+ back() {
326
+ window.history.back();
327
+ },
328
+
329
+ forward() {
330
+ window.history.forward();
331
+ },
332
+
333
+ prefetch(url: string) {
334
+ if (isInternalUrl(url)) {
335
+ prefetchUrl(url);
336
+ }
337
+ },
338
+
339
+ refresh() {
340
+ window.location.reload();
341
+ }
342
+ };
343
+ }
344
+
345
+ export default Link;
@@ -4,5 +4,9 @@
4
4
  */
5
5
 
6
6
  export { hydrateIsland, hydrateApp } from './hydration.js';
7
- export { navigate, prefetch, Link } from './navigation.js';
7
+ export { navigate, prefetch, Link as NavLink } from './navigation.js';
8
8
  export { useIsland, IslandBoundary } from './islands.js';
9
+
10
+ // Enhanced Link component with prefetching
11
+ export { Link, useRouter } from './Link.js';
12
+ export type { LinkProps } from './Link.js';