@object-ui/react 3.1.5 → 3.3.1

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +28 -3
  3. package/dist/SchemaRenderer.js +2 -2
  4. package/dist/components/form/FieldFactory.js +5 -2
  5. package/dist/context/AppShellContext.d.ts +26 -0
  6. package/dist/context/AppShellContext.js +67 -0
  7. package/dist/context/index.d.ts +1 -7
  8. package/dist/context/index.js +1 -7
  9. package/dist/hooks/index.d.ts +1 -0
  10. package/dist/hooks/index.js +1 -0
  11. package/dist/hooks/useDataRefresh.d.ts +36 -0
  12. package/dist/hooks/useDataRefresh.js +50 -0
  13. package/dist/hooks/useDiscovery.d.ts +12 -0
  14. package/dist/hooks/useDiscovery.js +6 -0
  15. package/dist/hooks/usePerformanceBudget.js +1 -1
  16. package/package.json +32 -9
  17. package/.turbo/turbo-build.log +0 -4
  18. package/src/LazyPluginLoader.tsx +0 -174
  19. package/src/SchemaRenderer.tsx +0 -266
  20. package/src/__tests__/LazyPluginLoader.test.tsx +0 -176
  21. package/src/__tests__/SchemaRenderer.aria.test.tsx +0 -118
  22. package/src/__tests__/SchemaRenderer.debug.test.tsx +0 -69
  23. package/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +0 -114
  24. package/src/__tests__/SchemaRenderer.expressions.test.tsx +0 -131
  25. package/src/__tests__/SchemaRendererProvider.smoke.test.tsx +0 -132
  26. package/src/__tests__/ThemeProvider.test.tsx +0 -451
  27. package/src/__tests__/useNavigationOverlay.test.ts +0 -578
  28. package/src/__tests__/useOffline.test.ts +0 -198
  29. package/src/__tests__/usePageTransition.test.ts +0 -214
  30. package/src/__tests__/usePageVariables.test.tsx +0 -334
  31. package/src/__tests__/usePerformance.test.ts +0 -137
  32. package/src/__tests__/useViewData.test.tsx +0 -422
  33. package/src/__tests__/view-switching.test.tsx +0 -456
  34. package/src/components/form/FieldFactory.tsx +0 -509
  35. package/src/components/form/FormRenderer.test.tsx +0 -744
  36. package/src/components/form/FormRenderer.tsx +0 -258
  37. package/src/components/form/README.md +0 -352
  38. package/src/components/form/index.ts +0 -12
  39. package/src/context/ActionContext.tsx +0 -213
  40. package/src/context/DndContext.tsx +0 -260
  41. package/src/context/NotificationContext.tsx +0 -280
  42. package/src/context/SchemaRendererContext.tsx +0 -48
  43. package/src/context/ThemeContext.tsx +0 -325
  44. package/src/context/__tests__/ActionContext.test.tsx +0 -174
  45. package/src/context/__tests__/DndContext.test.tsx +0 -150
  46. package/src/context/__tests__/NotificationContext.test.tsx +0 -176
  47. package/src/context/__tests__/useDataScope.test.tsx +0 -91
  48. package/src/context/index.ts +0 -13
  49. package/src/hooks/__tests__/useActionEngine.test.ts +0 -170
  50. package/src/hooks/__tests__/useActionRunner.test.ts +0 -153
  51. package/src/hooks/__tests__/useAnimation.test.ts +0 -82
  52. package/src/hooks/__tests__/useColumnSummary.test.ts +0 -93
  53. package/src/hooks/__tests__/useCrudShortcuts.test.ts +0 -85
  54. package/src/hooks/__tests__/useDebugMode.test.ts +0 -83
  55. package/src/hooks/__tests__/useDensityMode.test.ts +0 -81
  56. package/src/hooks/__tests__/useDiscovery.test.tsx +0 -149
  57. package/src/hooks/__tests__/useDynamicApp.test.ts +0 -161
  58. package/src/hooks/__tests__/useETagCache.test.ts +0 -153
  59. package/src/hooks/__tests__/useExpression.test.ts +0 -86
  60. package/src/hooks/__tests__/useFocusTrap.test.ts +0 -294
  61. package/src/hooks/__tests__/useKeyboardShortcuts.test.ts +0 -144
  62. package/src/hooks/__tests__/usePageTransition.test.ts +0 -158
  63. package/src/hooks/__tests__/usePerformanceBudget.test.ts +0 -157
  64. package/src/hooks/__tests__/useReducedMotion.test.ts +0 -104
  65. package/src/hooks/__tests__/useSchemaPersistence.test.ts +0 -220
  66. package/src/hooks/__tests__/useViewSharing.test.ts +0 -121
  67. package/src/hooks/__tests__/useViewTransition.test.ts +0 -61
  68. package/src/hooks/index.ts +0 -35
  69. package/src/hooks/useActionEngine.ts +0 -84
  70. package/src/hooks/useActionRunner.ts +0 -119
  71. package/src/hooks/useAnimation.ts +0 -117
  72. package/src/hooks/useClientNotifications.ts +0 -251
  73. package/src/hooks/useColumnSummary.ts +0 -87
  74. package/src/hooks/useCrudShortcuts.ts +0 -126
  75. package/src/hooks/useDebugMode.ts +0 -95
  76. package/src/hooks/useDensityMode.ts +0 -102
  77. package/src/hooks/useDiscovery.ts +0 -154
  78. package/src/hooks/useDynamicApp.ts +0 -142
  79. package/src/hooks/useETagCache.ts +0 -404
  80. package/src/hooks/useExpression.ts +0 -56
  81. package/src/hooks/useFocusManagement.ts +0 -248
  82. package/src/hooks/useFocusTrap.ts +0 -138
  83. package/src/hooks/useGlobalUndo.ts +0 -98
  84. package/src/hooks/useKeyboardShortcuts.ts +0 -119
  85. package/src/hooks/useNavigationOverlay.ts +0 -190
  86. package/src/hooks/useOffline.ts +0 -301
  87. package/src/hooks/usePageTransition.ts +0 -187
  88. package/src/hooks/usePageVariables.tsx +0 -157
  89. package/src/hooks/usePerformance.ts +0 -246
  90. package/src/hooks/usePerformanceBudget.ts +0 -313
  91. package/src/hooks/useReducedMotion.ts +0 -53
  92. package/src/hooks/useSchemaPersistence.ts +0 -222
  93. package/src/hooks/useViewData.ts +0 -201
  94. package/src/hooks/useViewSharing.ts +0 -140
  95. package/src/hooks/useViewTransition.ts +0 -144
  96. package/src/index.ts +0 -41
  97. package/src/spec-bridge/SpecBridge.ts +0 -64
  98. package/src/spec-bridge/__tests__/P1SpecBridge.test.ts +0 -855
  99. package/src/spec-bridge/__tests__/SpecBridge.test.ts +0 -550
  100. package/src/spec-bridge/bridges/dashboard.ts +0 -180
  101. package/src/spec-bridge/bridges/form-view.ts +0 -109
  102. package/src/spec-bridge/bridges/list-view.ts +0 -187
  103. package/src/spec-bridge/bridges/page.ts +0 -136
  104. package/src/spec-bridge/index.ts +0 -14
  105. package/src/spec-bridge/types.ts +0 -38
  106. package/src/utils/i18n.ts +0 -10
  107. package/tsconfig.json +0 -17
  108. package/tsconfig.tsbuildinfo +0 -1
  109. package/vitest.config.ts +0 -2
@@ -1,404 +0,0 @@
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
- }
@@ -1,56 +0,0 @@
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 { useMemo } from 'react';
10
- import { ExpressionEvaluator } from '@object-ui/core';
11
-
12
- /**
13
- * Hook for evaluating expressions with dynamic context
14
- *
15
- * @example
16
- * ```tsx
17
- * const isVisible = useExpression('${data.age >= 18}', { data });
18
- * const label = useExpression('Hello ${user.name}!', { user });
19
- * ```
20
- */
21
- export function useExpression(
22
- expression: string | boolean | number | null | undefined,
23
- context: Record<string, any> = {}
24
- ): any {
25
- // We evaluate directly without caching the evaluator to avoid issues with context changes
26
- return useMemo(
27
- () => {
28
- const evaluator = new ExpressionEvaluator(context);
29
- return evaluator.evaluate(expression);
30
- },
31
- [expression, context]
32
- );
33
- }
34
-
35
- /**
36
- * Hook for evaluating conditional expressions
37
- * Returns a boolean value
38
- *
39
- * @example
40
- * ```tsx
41
- * const isVisible = useCondition('${data.status === "active"}', { data });
42
- * ```
43
- */
44
- export function useCondition(
45
- condition: string | boolean | undefined,
46
- context: Record<string, any> = {}
47
- ): boolean {
48
- // We evaluate directly without caching the evaluator to avoid issues with context changes
49
- return useMemo(
50
- () => {
51
- const evaluator = new ExpressionEvaluator(context);
52
- return evaluator.evaluateCondition(condition);
53
- },
54
- [condition, context]
55
- );
56
- }
@@ -1,248 +0,0 @@
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
- }