@object-ui/react 3.3.0 → 3.3.2

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 (103) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +21 -1
  3. package/dist/SchemaRenderer.js +19 -2
  4. package/dist/context/AppShellContext.d.ts +26 -0
  5. package/dist/context/AppShellContext.js +67 -0
  6. package/dist/context/index.d.ts +1 -7
  7. package/dist/context/index.js +1 -7
  8. package/package.json +32 -9
  9. package/.turbo/turbo-build.log +0 -4
  10. package/src/LazyPluginLoader.tsx +0 -174
  11. package/src/SchemaRenderer.tsx +0 -266
  12. package/src/__tests__/LazyPluginLoader.test.tsx +0 -176
  13. package/src/__tests__/SchemaRenderer.aria.test.tsx +0 -118
  14. package/src/__tests__/SchemaRenderer.debug.test.tsx +0 -69
  15. package/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +0 -114
  16. package/src/__tests__/SchemaRenderer.expressions.test.tsx +0 -131
  17. package/src/__tests__/SchemaRendererProvider.smoke.test.tsx +0 -132
  18. package/src/__tests__/ThemeProvider.test.tsx +0 -451
  19. package/src/__tests__/useNavigationOverlay.test.ts +0 -578
  20. package/src/__tests__/useOffline.test.ts +0 -198
  21. package/src/__tests__/usePageTransition.test.ts +0 -214
  22. package/src/__tests__/usePageVariables.test.tsx +0 -334
  23. package/src/__tests__/usePerformance.test.ts +0 -137
  24. package/src/__tests__/useViewData.test.tsx +0 -422
  25. package/src/__tests__/view-switching.test.tsx +0 -456
  26. package/src/components/form/FieldFactory.tsx +0 -516
  27. package/src/components/form/FormRenderer.test.tsx +0 -744
  28. package/src/components/form/FormRenderer.tsx +0 -258
  29. package/src/components/form/README.md +0 -352
  30. package/src/components/form/index.ts +0 -12
  31. package/src/context/ActionContext.tsx +0 -213
  32. package/src/context/DndContext.tsx +0 -260
  33. package/src/context/NotificationContext.tsx +0 -280
  34. package/src/context/SchemaRendererContext.tsx +0 -48
  35. package/src/context/ThemeContext.tsx +0 -325
  36. package/src/context/__tests__/ActionContext.test.tsx +0 -174
  37. package/src/context/__tests__/DndContext.test.tsx +0 -150
  38. package/src/context/__tests__/NotificationContext.test.tsx +0 -176
  39. package/src/context/__tests__/useDataScope.test.tsx +0 -91
  40. package/src/context/index.ts +0 -13
  41. package/src/hooks/__tests__/useActionEngine.test.ts +0 -170
  42. package/src/hooks/__tests__/useActionRunner.test.ts +0 -153
  43. package/src/hooks/__tests__/useAnimation.test.ts +0 -82
  44. package/src/hooks/__tests__/useColumnSummary.test.ts +0 -93
  45. package/src/hooks/__tests__/useCrudShortcuts.test.ts +0 -85
  46. package/src/hooks/__tests__/useDataRefresh.test.ts +0 -115
  47. package/src/hooks/__tests__/useDebugMode.test.ts +0 -83
  48. package/src/hooks/__tests__/useDensityMode.test.ts +0 -81
  49. package/src/hooks/__tests__/useDiscovery.test.tsx +0 -247
  50. package/src/hooks/__tests__/useDynamicApp.test.ts +0 -161
  51. package/src/hooks/__tests__/useETagCache.test.ts +0 -153
  52. package/src/hooks/__tests__/useExpression.test.ts +0 -86
  53. package/src/hooks/__tests__/useFocusTrap.test.ts +0 -294
  54. package/src/hooks/__tests__/useKeyboardShortcuts.test.ts +0 -144
  55. package/src/hooks/__tests__/usePageTransition.test.ts +0 -158
  56. package/src/hooks/__tests__/usePerformanceBudget.test.ts +0 -157
  57. package/src/hooks/__tests__/useReducedMotion.test.ts +0 -104
  58. package/src/hooks/__tests__/useSchemaPersistence.test.ts +0 -220
  59. package/src/hooks/__tests__/useViewSharing.test.ts +0 -121
  60. package/src/hooks/__tests__/useViewTransition.test.ts +0 -61
  61. package/src/hooks/index.ts +0 -36
  62. package/src/hooks/useActionEngine.ts +0 -84
  63. package/src/hooks/useActionRunner.ts +0 -119
  64. package/src/hooks/useAnimation.ts +0 -117
  65. package/src/hooks/useClientNotifications.ts +0 -251
  66. package/src/hooks/useColumnSummary.ts +0 -87
  67. package/src/hooks/useCrudShortcuts.ts +0 -126
  68. package/src/hooks/useDataRefresh.ts +0 -58
  69. package/src/hooks/useDebugMode.ts +0 -95
  70. package/src/hooks/useDensityMode.ts +0 -102
  71. package/src/hooks/useDiscovery.ts +0 -168
  72. package/src/hooks/useDynamicApp.ts +0 -142
  73. package/src/hooks/useETagCache.ts +0 -404
  74. package/src/hooks/useExpression.ts +0 -56
  75. package/src/hooks/useFocusManagement.ts +0 -248
  76. package/src/hooks/useFocusTrap.ts +0 -138
  77. package/src/hooks/useGlobalUndo.ts +0 -98
  78. package/src/hooks/useKeyboardShortcuts.ts +0 -119
  79. package/src/hooks/useNavigationOverlay.ts +0 -190
  80. package/src/hooks/useOffline.ts +0 -301
  81. package/src/hooks/usePageTransition.ts +0 -187
  82. package/src/hooks/usePageVariables.tsx +0 -157
  83. package/src/hooks/usePerformance.ts +0 -246
  84. package/src/hooks/usePerformanceBudget.ts +0 -313
  85. package/src/hooks/useReducedMotion.ts +0 -53
  86. package/src/hooks/useSchemaPersistence.ts +0 -222
  87. package/src/hooks/useViewData.ts +0 -201
  88. package/src/hooks/useViewSharing.ts +0 -140
  89. package/src/hooks/useViewTransition.ts +0 -144
  90. package/src/index.ts +0 -41
  91. package/src/spec-bridge/SpecBridge.ts +0 -64
  92. package/src/spec-bridge/__tests__/P1SpecBridge.test.ts +0 -855
  93. package/src/spec-bridge/__tests__/SpecBridge.test.ts +0 -550
  94. package/src/spec-bridge/bridges/dashboard.ts +0 -180
  95. package/src/spec-bridge/bridges/form-view.ts +0 -109
  96. package/src/spec-bridge/bridges/list-view.ts +0 -187
  97. package/src/spec-bridge/bridges/page.ts +0 -136
  98. package/src/spec-bridge/index.ts +0 -14
  99. package/src/spec-bridge/types.ts +0 -38
  100. package/src/utils/i18n.ts +0 -10
  101. package/tsconfig.json +0 -17
  102. package/tsconfig.tsbuildinfo +0 -1
  103. 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
- }