@object-ui/react 2.0.0 → 3.0.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/SchemaRenderer.d.ts +20 -1
- package/dist/SchemaRenderer.js +76 -13
- package/dist/context/DndContext.d.ts +135 -0
- package/dist/context/DndContext.js +120 -0
- package/dist/context/NotificationContext.d.ts +113 -0
- package/dist/context/NotificationContext.js +130 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/hooks/index.d.ts +17 -0
- package/dist/hooks/index.js +17 -0
- package/dist/hooks/useAnimation.d.ts +46 -0
- package/dist/hooks/useAnimation.js +59 -0
- package/dist/hooks/useClientNotifications.d.ts +53 -0
- package/dist/hooks/useClientNotifications.js +190 -0
- package/dist/hooks/useColumnSummary.d.ts +38 -0
- package/dist/hooks/useColumnSummary.js +54 -0
- package/dist/hooks/useCrudShortcuts.d.ts +39 -0
- package/dist/hooks/useCrudShortcuts.js +96 -0
- package/dist/hooks/useDensityMode.d.ts +48 -0
- package/dist/hooks/useDensityMode.js +60 -0
- package/dist/hooks/useETagCache.d.ts +75 -0
- package/dist/hooks/useETagCache.js +306 -0
- package/dist/hooks/useFocusManagement.d.ts +89 -0
- package/dist/hooks/useFocusManagement.js +188 -0
- package/dist/hooks/useFocusTrap.d.ts +41 -0
- package/dist/hooks/useFocusTrap.js +103 -0
- package/dist/hooks/useKeyboardShortcuts.d.ts +49 -0
- package/dist/hooks/useKeyboardShortcuts.js +86 -0
- package/dist/hooks/useOffline.d.ts +96 -0
- package/dist/hooks/useOffline.js +185 -0
- package/dist/hooks/usePageTransition.d.ts +60 -0
- package/dist/hooks/usePageTransition.js +111 -0
- package/dist/hooks/usePerformance.d.ts +87 -0
- package/dist/hooks/usePerformance.js +165 -0
- package/dist/hooks/usePerformanceBudget.d.ts +78 -0
- package/dist/hooks/usePerformanceBudget.js +209 -0
- package/dist/hooks/useReducedMotion.d.ts +28 -0
- package/dist/hooks/useReducedMotion.js +48 -0
- package/dist/hooks/useSchemaPersistence.d.ts +87 -0
- package/dist/hooks/useSchemaPersistence.js +173 -0
- package/dist/hooks/useViewSharing.d.ts +57 -0
- package/dist/hooks/useViewSharing.js +74 -0
- package/dist/hooks/useViewTransition.d.ts +54 -0
- package/dist/hooks/useViewTransition.js +83 -0
- package/package.json +7 -7
- package/src/SchemaRenderer.tsx +122 -13
- package/src/__tests__/SchemaRenderer.aria.test.tsx +118 -0
- package/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +114 -0
- package/src/__tests__/SchemaRendererProvider.smoke.test.tsx +132 -0
- package/src/__tests__/useOffline.test.ts +198 -0
- package/src/__tests__/usePageTransition.test.ts +214 -0
- package/src/__tests__/usePerformance.test.ts +137 -0
- package/src/__tests__/view-switching.test.tsx +456 -0
- package/src/context/DndContext.tsx +260 -0
- package/src/context/NotificationContext.tsx +280 -0
- package/src/context/__tests__/DndContext.test.tsx +150 -0
- package/src/context/__tests__/NotificationContext.test.tsx +176 -0
- package/src/context/index.ts +2 -0
- package/src/hooks/__tests__/useAnimation.test.ts +82 -0
- package/src/hooks/__tests__/useColumnSummary.test.ts +93 -0
- package/src/hooks/__tests__/useDensityMode.test.ts +81 -0
- package/src/hooks/__tests__/useETagCache.test.ts +153 -0
- package/src/hooks/__tests__/useFocusTrap.test.ts +47 -0
- package/src/hooks/__tests__/useKeyboardShortcuts.test.ts +144 -0
- package/src/hooks/__tests__/usePerformanceBudget.test.ts +157 -0
- package/src/hooks/__tests__/useSchemaPersistence.test.ts +220 -0
- package/src/hooks/__tests__/useViewSharing.test.ts +121 -0
- package/src/hooks/__tests__/useViewTransition.test.ts +61 -0
- package/src/hooks/index.ts +17 -1
- package/src/hooks/useAnimation.ts +117 -0
- package/src/hooks/useClientNotifications.ts +251 -0
- package/src/hooks/useColumnSummary.ts +87 -0
- package/src/hooks/useCrudShortcuts.ts +126 -0
- package/src/hooks/useDensityMode.ts +102 -0
- package/src/hooks/useETagCache.ts +404 -0
- package/src/hooks/useFocusManagement.ts +248 -0
- package/src/hooks/useFocusTrap.ts +138 -0
- package/src/hooks/useKeyboardShortcuts.ts +119 -0
- package/src/hooks/useOffline.ts +301 -0
- package/src/hooks/usePageTransition.ts +187 -0
- package/src/hooks/usePerformance.ts +246 -0
- package/src/hooks/usePerformanceBudget.ts +313 -0
- package/src/hooks/useReducedMotion.ts +53 -0
- package/src/hooks/useSchemaPersistence.ts +222 -0
- package/src/hooks/useViewSharing.ts +140 -0
- package/src/hooks/useViewTransition.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types aligned with @objectstack/client ETag caching
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Configuration for ETag-based HTTP caching. */
|
|
16
|
+
export interface ETagCacheConfig {
|
|
17
|
+
/** Enable ETag caching (default: true) */
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
/** Storage backend for ETags ('memory' | 'localStorage') */
|
|
20
|
+
storage?: 'memory' | 'localStorage';
|
|
21
|
+
/** Storage key prefix (default: 'oui-etag') */
|
|
22
|
+
storagePrefix?: string;
|
|
23
|
+
/** Max cache entries (default: 1000) */
|
|
24
|
+
maxEntries?: number;
|
|
25
|
+
/** Cache TTL in ms (default: 3600000 = 1 hour) */
|
|
26
|
+
ttl?: number;
|
|
27
|
+
/** Whether to register a Service Worker for caching (default: false) */
|
|
28
|
+
useServiceWorker?: boolean;
|
|
29
|
+
/** Service Worker script URL */
|
|
30
|
+
serviceWorkerUrl?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single cache entry with its associated ETag and metadata. */
|
|
34
|
+
export interface CacheEntry<T = unknown> {
|
|
35
|
+
data: T;
|
|
36
|
+
etag: string;
|
|
37
|
+
url: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
lastModified?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Result returned by the useETagCache hook. */
|
|
43
|
+
export interface ETagCacheResult {
|
|
44
|
+
/** Fetch data with ETag support — sends If-None-Match header, returns cached data on 304. */
|
|
45
|
+
fetchWithETag: <T>(
|
|
46
|
+
url: string,
|
|
47
|
+
options?: RequestInit,
|
|
48
|
+
) => Promise<{ data: T; fromCache: boolean; etag?: string }>;
|
|
49
|
+
/** Invalidate cache for a specific URL. */
|
|
50
|
+
invalidate: (url: string) => void;
|
|
51
|
+
/** Invalidate all cache entries matching a pattern. */
|
|
52
|
+
invalidatePattern: (pattern: string | RegExp) => void;
|
|
53
|
+
/** Clear entire cache. */
|
|
54
|
+
clearCache: () => void;
|
|
55
|
+
/** Number of cached entries. */
|
|
56
|
+
cacheSize: number;
|
|
57
|
+
/** Whether Service Worker is registered and active. */
|
|
58
|
+
serviceWorkerActive: boolean;
|
|
59
|
+
/** Register/update the Service Worker. */
|
|
60
|
+
registerServiceWorker: () => Promise<boolean>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Defaults
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const DEFAULTS = {
|
|
68
|
+
enabled: true,
|
|
69
|
+
storage: 'memory' as const,
|
|
70
|
+
storagePrefix: 'oui-etag',
|
|
71
|
+
maxEntries: 1000,
|
|
72
|
+
ttl: 3_600_000, // 1 hour
|
|
73
|
+
useServiceWorker: false,
|
|
74
|
+
serviceWorkerUrl: '/service-worker.js',
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// LRU cache implementation (module-scoped, shared across instances)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const memoryCache = new Map<string, CacheEntry>();
|
|
82
|
+
|
|
83
|
+
/** Access order tracking for LRU eviction — most recently used at the end. */
|
|
84
|
+
const accessOrder: string[] = [];
|
|
85
|
+
|
|
86
|
+
function touchAccessOrder(url: string): void {
|
|
87
|
+
const idx = accessOrder.indexOf(url);
|
|
88
|
+
if (idx !== -1) accessOrder.splice(idx, 1);
|
|
89
|
+
accessOrder.push(url);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function evictLRU(maxEntries: number): void {
|
|
93
|
+
while (memoryCache.size > maxEntries && accessOrder.length > 0) {
|
|
94
|
+
const oldest = accessOrder.shift();
|
|
95
|
+
if (oldest) memoryCache.delete(oldest);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// localStorage helpers (SSR-safe)
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function loadFromLocalStorage(prefix: string): Map<string, CacheEntry> {
|
|
104
|
+
if (typeof localStorage === 'undefined') return new Map();
|
|
105
|
+
const entries = new Map<string, CacheEntry>();
|
|
106
|
+
try {
|
|
107
|
+
const raw = localStorage.getItem(`${prefix}:index`);
|
|
108
|
+
if (!raw) return entries;
|
|
109
|
+
const keys = JSON.parse(raw) as string[];
|
|
110
|
+
for (const key of keys) {
|
|
111
|
+
const item = localStorage.getItem(`${prefix}:${key}`);
|
|
112
|
+
if (item) {
|
|
113
|
+
entries.set(key, JSON.parse(item) as CacheEntry);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// corrupted storage — ignore
|
|
118
|
+
}
|
|
119
|
+
return entries;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function persistToLocalStorage(prefix: string, cache: Map<string, CacheEntry>): void {
|
|
123
|
+
if (typeof localStorage === 'undefined') return;
|
|
124
|
+
try {
|
|
125
|
+
const keys = Array.from(cache.keys());
|
|
126
|
+
localStorage.setItem(`${prefix}:index`, JSON.stringify(keys));
|
|
127
|
+
for (const [key, entry] of cache) {
|
|
128
|
+
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(entry));
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// localStorage full or unavailable — silently ignore
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function clearLocalStorage(prefix: string): void {
|
|
136
|
+
if (typeof localStorage === 'undefined') return;
|
|
137
|
+
try {
|
|
138
|
+
const raw = localStorage.getItem(`${prefix}:index`);
|
|
139
|
+
if (raw) {
|
|
140
|
+
const keys = JSON.parse(raw) as string[];
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
localStorage.removeItem(`${prefix}:${key}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
localStorage.removeItem(`${prefix}:index`);
|
|
146
|
+
} catch {
|
|
147
|
+
// ignore
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function removeFromLocalStorage(prefix: string, url: string): void {
|
|
152
|
+
if (typeof localStorage === 'undefined') return;
|
|
153
|
+
try {
|
|
154
|
+
localStorage.removeItem(`${prefix}:${url}`);
|
|
155
|
+
const raw = localStorage.getItem(`${prefix}:index`);
|
|
156
|
+
if (raw) {
|
|
157
|
+
const keys = (JSON.parse(raw) as string[]).filter((k) => k !== url);
|
|
158
|
+
localStorage.setItem(`${prefix}:index`, JSON.stringify(keys));
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// useETagCache hook
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Hook for ETag-based HTTP caching with optional Service Worker support.
|
|
171
|
+
* Integrates with @objectstack/client ETag caching for efficient data fetching.
|
|
172
|
+
* When the server responds with 304 Not Modified, cached data is reused.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```tsx
|
|
176
|
+
* function UserProfile({ userId }: { userId: string }) {
|
|
177
|
+
* const cache = useETagCache({ storage: 'localStorage', ttl: 60_000 });
|
|
178
|
+
*
|
|
179
|
+
* useEffect(() => {
|
|
180
|
+
* cache.fetchWithETag<User>(`/api/users/${userId}`).then(({ data, fromCache }) => {
|
|
181
|
+
* console.log(fromCache ? 'Served from cache' : 'Fresh data');
|
|
182
|
+
* setUser(data);
|
|
183
|
+
* });
|
|
184
|
+
* }, [userId]);
|
|
185
|
+
*
|
|
186
|
+
* return <div>Cache entries: {cache.cacheSize}</div>;
|
|
187
|
+
* }
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
export function useETagCache(userConfig: ETagCacheConfig = {}): ETagCacheResult {
|
|
191
|
+
const enabled = userConfig.enabled ?? DEFAULTS.enabled;
|
|
192
|
+
const storage = userConfig.storage ?? DEFAULTS.storage;
|
|
193
|
+
const storagePrefix = userConfig.storagePrefix ?? DEFAULTS.storagePrefix;
|
|
194
|
+
const maxEntries = userConfig.maxEntries ?? DEFAULTS.maxEntries;
|
|
195
|
+
const ttl = userConfig.ttl ?? DEFAULTS.ttl;
|
|
196
|
+
const useSW = userConfig.useServiceWorker ?? DEFAULTS.useServiceWorker;
|
|
197
|
+
const swUrl = userConfig.serviceWorkerUrl ?? DEFAULTS.serviceWorkerUrl;
|
|
198
|
+
|
|
199
|
+
const [cacheSize, setCacheSize] = useState(0);
|
|
200
|
+
const [serviceWorkerActive, setServiceWorkerActive] = useState(false);
|
|
201
|
+
|
|
202
|
+
// Keep config in a ref so callbacks always see latest values
|
|
203
|
+
const configRef = useRef({ enabled, storage, storagePrefix, maxEntries, ttl });
|
|
204
|
+
configRef.current = { enabled, storage, storagePrefix, maxEntries, ttl };
|
|
205
|
+
|
|
206
|
+
// Hydrate memory cache from localStorage on mount
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (storage === 'localStorage') {
|
|
209
|
+
const persisted = loadFromLocalStorage(storagePrefix);
|
|
210
|
+
for (const [key, entry] of persisted) {
|
|
211
|
+
if (!memoryCache.has(key)) {
|
|
212
|
+
memoryCache.set(key, entry);
|
|
213
|
+
touchAccessOrder(key);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
evictLRU(maxEntries);
|
|
217
|
+
setCacheSize(memoryCache.size);
|
|
218
|
+
}
|
|
219
|
+
// Only run on mount
|
|
220
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
// Auto-register Service Worker when enabled
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!useSW) return;
|
|
226
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
|
227
|
+
|
|
228
|
+
navigator.serviceWorker
|
|
229
|
+
.register(swUrl, { scope: '/' })
|
|
230
|
+
.then((registration) => {
|
|
231
|
+
const worker = registration.active ?? registration.installing ?? registration.waiting;
|
|
232
|
+
setServiceWorkerActive(worker?.state === 'activated');
|
|
233
|
+
|
|
234
|
+
registration.onupdatefound = () => {
|
|
235
|
+
const installing = registration.installing;
|
|
236
|
+
if (!installing) return;
|
|
237
|
+
installing.onstatechange = () => {
|
|
238
|
+
if (installing.state === 'activated') {
|
|
239
|
+
setServiceWorkerActive(true);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
})
|
|
244
|
+
.catch(() => {
|
|
245
|
+
setServiceWorkerActive(false);
|
|
246
|
+
});
|
|
247
|
+
}, [useSW, swUrl]);
|
|
248
|
+
|
|
249
|
+
// ----- Helpers that read/write the shared cache -----
|
|
250
|
+
|
|
251
|
+
const isExpired = useCallback((entry: CacheEntry): boolean => {
|
|
252
|
+
return Date.now() - entry.timestamp > configRef.current.ttl;
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
const setEntry = useCallback(
|
|
256
|
+
(url: string, entry: CacheEntry): void => {
|
|
257
|
+
memoryCache.set(url, entry);
|
|
258
|
+
touchAccessOrder(url);
|
|
259
|
+
evictLRU(configRef.current.maxEntries);
|
|
260
|
+
|
|
261
|
+
if (configRef.current.storage === 'localStorage') {
|
|
262
|
+
persistToLocalStorage(configRef.current.storagePrefix, memoryCache);
|
|
263
|
+
}
|
|
264
|
+
setCacheSize(memoryCache.size);
|
|
265
|
+
},
|
|
266
|
+
[],
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const removeEntry = useCallback(
|
|
270
|
+
(url: string): void => {
|
|
271
|
+
memoryCache.delete(url);
|
|
272
|
+
const idx = accessOrder.indexOf(url);
|
|
273
|
+
if (idx !== -1) accessOrder.splice(idx, 1);
|
|
274
|
+
|
|
275
|
+
if (configRef.current.storage === 'localStorage') {
|
|
276
|
+
removeFromLocalStorage(configRef.current.storagePrefix, url);
|
|
277
|
+
}
|
|
278
|
+
setCacheSize(memoryCache.size);
|
|
279
|
+
},
|
|
280
|
+
[],
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// ----- Public API -----
|
|
284
|
+
|
|
285
|
+
const fetchWithETag = useCallback(
|
|
286
|
+
async <T>(
|
|
287
|
+
url: string,
|
|
288
|
+
options?: RequestInit,
|
|
289
|
+
): Promise<{ data: T; fromCache: boolean; etag?: string }> => {
|
|
290
|
+
// Validate URL: only allow http(s) or relative URLs
|
|
291
|
+
if (url.includes('://')) {
|
|
292
|
+
try {
|
|
293
|
+
const parsed = new URL(url);
|
|
294
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
295
|
+
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
|
|
296
|
+
}
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (e instanceof Error && e.message.startsWith('Unsupported')) throw e;
|
|
299
|
+
throw new Error('Invalid URL');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!configRef.current.enabled) {
|
|
304
|
+
const res = await fetch(url, options);
|
|
305
|
+
const data = (await res.json()) as T;
|
|
306
|
+
return { data, fromCache: false, etag: res.headers.get('etag') ?? undefined };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const cached = memoryCache.get(url) as CacheEntry<T> | undefined;
|
|
310
|
+
const headers = new Headers(options?.headers);
|
|
311
|
+
|
|
312
|
+
if (cached && !isExpired(cached)) {
|
|
313
|
+
if (cached.etag) {
|
|
314
|
+
headers.set('If-None-Match', cached.etag);
|
|
315
|
+
}
|
|
316
|
+
if (cached.lastModified) {
|
|
317
|
+
headers.set('If-Modified-Since', cached.lastModified);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const res = await fetch(url, { ...options, headers });
|
|
322
|
+
|
|
323
|
+
// 304 Not Modified — serve from cache
|
|
324
|
+
if (res.status === 304 && cached) {
|
|
325
|
+
touchAccessOrder(url);
|
|
326
|
+
return { data: cached.data, fromCache: true, etag: cached.etag };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const data = (await res.json()) as T;
|
|
330
|
+
const etag = res.headers.get('etag') ?? undefined;
|
|
331
|
+
const lastModified = res.headers.get('last-modified') ?? undefined;
|
|
332
|
+
|
|
333
|
+
if (etag || lastModified) {
|
|
334
|
+
setEntry(url, {
|
|
335
|
+
data,
|
|
336
|
+
etag: etag ?? '',
|
|
337
|
+
url,
|
|
338
|
+
timestamp: Date.now(),
|
|
339
|
+
lastModified,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { data, fromCache: false, etag };
|
|
344
|
+
},
|
|
345
|
+
[isExpired, setEntry],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const invalidate = useCallback(
|
|
349
|
+
(url: string): void => {
|
|
350
|
+
removeEntry(url);
|
|
351
|
+
},
|
|
352
|
+
[removeEntry],
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const invalidatePattern = useCallback(
|
|
356
|
+
(pattern: string | RegExp): void => {
|
|
357
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
358
|
+
const keysToRemove = Array.from(memoryCache.keys()).filter((key) => regex.test(key));
|
|
359
|
+
for (const key of keysToRemove) {
|
|
360
|
+
removeEntry(key);
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
[removeEntry],
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const clearCache = useCallback((): void => {
|
|
367
|
+
memoryCache.clear();
|
|
368
|
+
accessOrder.length = 0;
|
|
369
|
+
|
|
370
|
+
if (configRef.current.storage === 'localStorage') {
|
|
371
|
+
clearLocalStorage(configRef.current.storagePrefix);
|
|
372
|
+
}
|
|
373
|
+
setCacheSize(0);
|
|
374
|
+
}, []);
|
|
375
|
+
|
|
376
|
+
const registerSW = useCallback(async (): Promise<boolean> => {
|
|
377
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const registration = await navigator.serviceWorker.register(swUrl, { scope: '/' });
|
|
382
|
+
const worker = registration.active ?? registration.installing ?? registration.waiting;
|
|
383
|
+
const active = worker?.state === 'activated';
|
|
384
|
+
setServiceWorkerActive(active);
|
|
385
|
+
return active;
|
|
386
|
+
} catch {
|
|
387
|
+
setServiceWorkerActive(false);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}, [swUrl]);
|
|
391
|
+
|
|
392
|
+
return useMemo<ETagCacheResult>(
|
|
393
|
+
() => ({
|
|
394
|
+
fetchWithETag,
|
|
395
|
+
invalidate,
|
|
396
|
+
invalidatePattern,
|
|
397
|
+
clearCache,
|
|
398
|
+
cacheSize,
|
|
399
|
+
serviceWorkerActive,
|
|
400
|
+
registerServiceWorker: registerSW,
|
|
401
|
+
}),
|
|
402
|
+
[fetchWithETag, invalidate, invalidatePattern, clearCache, cacheSize, serviceWorkerActive, registerSW],
|
|
403
|
+
);
|
|
404
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef, useCallback, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
// ─── useFocusReturn ────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface FocusReturnOptions {
|
|
14
|
+
/** Whether focus restoration is enabled. @default true */
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
/** Whether to focus the previously active element on unmount. @default true */
|
|
17
|
+
restoreOnUnmount?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hook that captures the currently focused element on mount and restores
|
|
22
|
+
* focus to it when the component unmounts. Useful for modals, drawers,
|
|
23
|
+
* and overlays that temporarily steal focus.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* function Drawer({ onClose }: { onClose: () => void }) {
|
|
28
|
+
* const { returnFocus } = useFocusReturn();
|
|
29
|
+
*
|
|
30
|
+
* const handleClose = () => {
|
|
31
|
+
* returnFocus();
|
|
32
|
+
* onClose();
|
|
33
|
+
* };
|
|
34
|
+
*
|
|
35
|
+
* return <aside>...</aside>;
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function useFocusReturn(options: FocusReturnOptions = {}): {
|
|
40
|
+
/** Manually restore focus to the previously focused element. */
|
|
41
|
+
returnFocus: () => void;
|
|
42
|
+
} {
|
|
43
|
+
const { enabled = true, restoreOnUnmount = true } = options;
|
|
44
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
45
|
+
|
|
46
|
+
// Capture focused element on mount
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!enabled) return;
|
|
49
|
+
previousFocusRef.current = document.activeElement as HTMLElement | null;
|
|
50
|
+
}, [enabled]);
|
|
51
|
+
|
|
52
|
+
const returnFocus = useCallback(() => {
|
|
53
|
+
if (!enabled) return;
|
|
54
|
+
const el = previousFocusRef.current;
|
|
55
|
+
if (el && typeof el.focus === 'function' && document.body.contains(el)) {
|
|
56
|
+
el.focus();
|
|
57
|
+
}
|
|
58
|
+
}, [enabled]);
|
|
59
|
+
|
|
60
|
+
// Restore on unmount
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!enabled || !restoreOnUnmount) return;
|
|
63
|
+
return () => {
|
|
64
|
+
const el = previousFocusRef.current;
|
|
65
|
+
if (el && typeof el.focus === 'function' && document.body.contains(el)) {
|
|
66
|
+
el.focus();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// Only run cleanup on unmount — intentionally stable deps
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
return { returnFocus };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── useRovingFocus ────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export type RovingOrientation = 'horizontal' | 'vertical' | 'both';
|
|
79
|
+
|
|
80
|
+
export interface RovingFocusOptions {
|
|
81
|
+
/** Whether roving focus is enabled. @default true */
|
|
82
|
+
enabled?: boolean;
|
|
83
|
+
/** Navigation orientation. @default 'vertical' */
|
|
84
|
+
orientation?: RovingOrientation;
|
|
85
|
+
/** Whether focus wraps from last to first item and vice-versa. @default true */
|
|
86
|
+
loop?: boolean;
|
|
87
|
+
/** Callback fired when the focused index changes. */
|
|
88
|
+
onFocusChange?: (index: number) => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RovingFocusReturn<T extends HTMLElement> {
|
|
92
|
+
/** Ref to attach to the container element. */
|
|
93
|
+
containerRef: React.RefObject<T | null>;
|
|
94
|
+
/** The index of the currently focused item (-1 if none). */
|
|
95
|
+
focusedIndex: number;
|
|
96
|
+
/** Programmatically focus a specific item by index. */
|
|
97
|
+
setFocusedIndex: (index: number) => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Hook that implements roving tabindex / arrow-key navigation for a list of
|
|
102
|
+
* focusable items inside a container (sidebar items, tab lists, menu items).
|
|
103
|
+
*
|
|
104
|
+
* Only the currently active item participates in the tab order (tabindex 0);
|
|
105
|
+
* all other items are removed from the tab order (tabindex -1). Arrow keys
|
|
106
|
+
* move focus between items.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* function SidebarNav({ items }: { items: string[] }) {
|
|
111
|
+
* const { containerRef, focusedIndex } = useRovingFocus<HTMLUListElement>({
|
|
112
|
+
* orientation: 'vertical',
|
|
113
|
+
* loop: true,
|
|
114
|
+
* });
|
|
115
|
+
*
|
|
116
|
+
* return (
|
|
117
|
+
* <ul ref={containerRef} role="tablist">
|
|
118
|
+
* {items.map((item, i) => (
|
|
119
|
+
* <li
|
|
120
|
+
* key={item}
|
|
121
|
+
* role="tab"
|
|
122
|
+
* tabIndex={i === focusedIndex ? 0 : -1}
|
|
123
|
+
* data-roving-item
|
|
124
|
+
* >
|
|
125
|
+
* {item}
|
|
126
|
+
* </li>
|
|
127
|
+
* ))}
|
|
128
|
+
* </ul>
|
|
129
|
+
* );
|
|
130
|
+
* }
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export function useRovingFocus<T extends HTMLElement = HTMLDivElement>(
|
|
134
|
+
options: RovingFocusOptions = {}
|
|
135
|
+
): RovingFocusReturn<T> {
|
|
136
|
+
const {
|
|
137
|
+
enabled = true,
|
|
138
|
+
orientation = 'vertical',
|
|
139
|
+
loop = true,
|
|
140
|
+
onFocusChange,
|
|
141
|
+
} = options;
|
|
142
|
+
|
|
143
|
+
const containerRef = useRef<T | null>(null);
|
|
144
|
+
const [focusedIndex, setFocusedIndexState] = useState(-1);
|
|
145
|
+
const onFocusChangeRef = useRef(onFocusChange);
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
onFocusChangeRef.current = onFocusChange;
|
|
148
|
+
}, [onFocusChange]);
|
|
149
|
+
|
|
150
|
+
const getItems = useCallback((): HTMLElement[] => {
|
|
151
|
+
if (!containerRef.current) return [];
|
|
152
|
+
return Array.from(
|
|
153
|
+
containerRef.current.querySelectorAll<HTMLElement>('[data-roving-item]')
|
|
154
|
+
).filter((el) => {
|
|
155
|
+
// Exclude hidden or disabled elements
|
|
156
|
+
if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return el.offsetParent !== null; // visible check
|
|
160
|
+
});
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const setFocusedIndex = useCallback(
|
|
164
|
+
(index: number) => {
|
|
165
|
+
const items = getItems();
|
|
166
|
+
if (items.length === 0) return;
|
|
167
|
+
|
|
168
|
+
const clamped = Math.max(0, Math.min(index, items.length - 1));
|
|
169
|
+
setFocusedIndexState(clamped);
|
|
170
|
+
items[clamped]?.focus();
|
|
171
|
+
onFocusChangeRef.current?.(clamped);
|
|
172
|
+
},
|
|
173
|
+
[getItems]
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!enabled || !containerRef.current) return;
|
|
178
|
+
|
|
179
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
180
|
+
const items = getItems();
|
|
181
|
+
if (items.length === 0) return;
|
|
182
|
+
|
|
183
|
+
const currentIndex = items.findIndex((el) => el === document.activeElement);
|
|
184
|
+
if (currentIndex === -1) return;
|
|
185
|
+
|
|
186
|
+
const prevKeys =
|
|
187
|
+
orientation === 'horizontal' ? ['ArrowLeft'] :
|
|
188
|
+
orientation === 'vertical' ? ['ArrowUp'] :
|
|
189
|
+
['ArrowUp', 'ArrowLeft'];
|
|
190
|
+
|
|
191
|
+
const nextKeys =
|
|
192
|
+
orientation === 'horizontal' ? ['ArrowRight'] :
|
|
193
|
+
orientation === 'vertical' ? ['ArrowDown'] :
|
|
194
|
+
['ArrowDown', 'ArrowRight'];
|
|
195
|
+
|
|
196
|
+
let nextIndex: number | null = null;
|
|
197
|
+
|
|
198
|
+
if (prevKeys.includes(event.key)) {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
if (currentIndex > 0) {
|
|
201
|
+
nextIndex = currentIndex - 1;
|
|
202
|
+
} else if (loop) {
|
|
203
|
+
nextIndex = items.length - 1;
|
|
204
|
+
}
|
|
205
|
+
} else if (nextKeys.includes(event.key)) {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
if (currentIndex < items.length - 1) {
|
|
208
|
+
nextIndex = currentIndex + 1;
|
|
209
|
+
} else if (loop) {
|
|
210
|
+
nextIndex = 0;
|
|
211
|
+
}
|
|
212
|
+
} else if (event.key === 'Home') {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
nextIndex = 0;
|
|
215
|
+
} else if (event.key === 'End') {
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
nextIndex = items.length - 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (nextIndex !== null) {
|
|
221
|
+
setFocusedIndexState(nextIndex);
|
|
222
|
+
items[nextIndex].focus();
|
|
223
|
+
onFocusChangeRef.current?.(nextIndex);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// When an item receives focus (e.g. via click), sync the index
|
|
228
|
+
const handleFocusIn = (event: FocusEvent) => {
|
|
229
|
+
const items = getItems();
|
|
230
|
+
const index = items.findIndex((el) => el === event.target);
|
|
231
|
+
if (index !== -1) {
|
|
232
|
+
setFocusedIndexState(index);
|
|
233
|
+
onFocusChangeRef.current?.(index);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const container = containerRef.current;
|
|
238
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
239
|
+
container.addEventListener('focusin', handleFocusIn);
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
243
|
+
container.removeEventListener('focusin', handleFocusIn);
|
|
244
|
+
};
|
|
245
|
+
}, [enabled, orientation, loop, getItems]);
|
|
246
|
+
|
|
247
|
+
return { containerRef, focusedIndex, setFocusedIndex };
|
|
248
|
+
}
|