@object-ui/react 0.5.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.
Files changed (144) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/dist/LazyPluginLoader.d.ts +44 -0
  4. package/dist/LazyPluginLoader.js +92 -3
  5. package/dist/SchemaRenderer.d.ts +20 -1
  6. package/dist/SchemaRenderer.js +76 -13
  7. package/dist/components/form/FieldFactory.js +44 -40
  8. package/dist/components/form/FormRenderer.js +45 -3
  9. package/dist/context/ActionContext.d.ts +76 -0
  10. package/dist/context/ActionContext.js +139 -0
  11. package/dist/context/DndContext.d.ts +135 -0
  12. package/dist/context/DndContext.js +120 -0
  13. package/dist/context/NotificationContext.d.ts +113 -0
  14. package/dist/context/NotificationContext.js +130 -0
  15. package/dist/context/ThemeContext.d.ts +94 -0
  16. package/dist/context/ThemeContext.js +215 -0
  17. package/dist/context/index.d.ts +4 -0
  18. package/dist/context/index.js +4 -0
  19. package/dist/hooks/index.d.ts +22 -0
  20. package/dist/hooks/index.js +22 -0
  21. package/dist/hooks/useActionRunner.d.ts +21 -7
  22. package/dist/hooks/useActionRunner.js +27 -7
  23. package/dist/hooks/useAnimation.d.ts +46 -0
  24. package/dist/hooks/useAnimation.js +59 -0
  25. package/dist/hooks/useClientNotifications.d.ts +53 -0
  26. package/dist/hooks/useClientNotifications.js +190 -0
  27. package/dist/hooks/useColumnSummary.d.ts +38 -0
  28. package/dist/hooks/useColumnSummary.js +54 -0
  29. package/dist/hooks/useCrudShortcuts.d.ts +39 -0
  30. package/dist/hooks/useCrudShortcuts.js +96 -0
  31. package/dist/hooks/useDensityMode.d.ts +48 -0
  32. package/dist/hooks/useDensityMode.js +60 -0
  33. package/dist/hooks/useDiscovery.d.ts +78 -0
  34. package/dist/hooks/useDiscovery.js +96 -0
  35. package/dist/hooks/useDynamicApp.d.ts +70 -0
  36. package/dist/hooks/useDynamicApp.js +92 -0
  37. package/dist/hooks/useETagCache.d.ts +75 -0
  38. package/dist/hooks/useETagCache.js +306 -0
  39. package/dist/hooks/useFocusManagement.d.ts +89 -0
  40. package/dist/hooks/useFocusManagement.js +188 -0
  41. package/dist/hooks/useFocusTrap.d.ts +41 -0
  42. package/dist/hooks/useFocusTrap.js +103 -0
  43. package/dist/hooks/useKeyboardShortcuts.d.ts +49 -0
  44. package/dist/hooks/useKeyboardShortcuts.js +86 -0
  45. package/dist/hooks/useNavigationOverlay.d.ts +78 -0
  46. package/dist/hooks/useNavigationOverlay.js +112 -0
  47. package/dist/hooks/useOffline.d.ts +96 -0
  48. package/dist/hooks/useOffline.js +185 -0
  49. package/dist/hooks/usePageTransition.d.ts +60 -0
  50. package/dist/hooks/usePageTransition.js +111 -0
  51. package/dist/hooks/usePageVariables.d.ts +67 -0
  52. package/dist/hooks/usePageVariables.js +109 -0
  53. package/dist/hooks/usePerformance.d.ts +87 -0
  54. package/dist/hooks/usePerformance.js +165 -0
  55. package/dist/hooks/usePerformanceBudget.d.ts +78 -0
  56. package/dist/hooks/usePerformanceBudget.js +209 -0
  57. package/dist/hooks/useReducedMotion.d.ts +28 -0
  58. package/dist/hooks/useReducedMotion.js +48 -0
  59. package/dist/hooks/useSchemaPersistence.d.ts +87 -0
  60. package/dist/hooks/useSchemaPersistence.js +173 -0
  61. package/dist/hooks/useViewData.d.ts +73 -0
  62. package/dist/hooks/useViewData.js +135 -0
  63. package/dist/hooks/useViewSharing.d.ts +57 -0
  64. package/dist/hooks/useViewSharing.js +74 -0
  65. package/dist/hooks/useViewTransition.d.ts +54 -0
  66. package/dist/hooks/useViewTransition.js +83 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +2 -0
  69. package/dist/utils/i18n.d.ts +10 -0
  70. package/dist/utils/i18n.js +12 -0
  71. package/package.json +7 -5
  72. package/src/LazyPluginLoader.tsx +129 -7
  73. package/src/SchemaRenderer.tsx +122 -13
  74. package/src/__tests__/LazyPluginLoader.test.tsx +97 -1
  75. package/src/__tests__/SchemaRenderer.aria.test.tsx +118 -0
  76. package/src/__tests__/SchemaRenderer.errorBoundary.test.tsx +114 -0
  77. package/src/__tests__/SchemaRendererProvider.smoke.test.tsx +132 -0
  78. package/src/__tests__/ThemeProvider.test.tsx +451 -0
  79. package/src/__tests__/useNavigationOverlay.test.ts +416 -0
  80. package/src/__tests__/useOffline.test.ts +198 -0
  81. package/src/__tests__/usePageTransition.test.ts +214 -0
  82. package/src/__tests__/usePageVariables.test.tsx +334 -0
  83. package/src/__tests__/usePerformance.test.ts +137 -0
  84. package/src/__tests__/useViewData.test.tsx +422 -0
  85. package/src/__tests__/view-switching.test.tsx +456 -0
  86. package/src/components/form/FieldFactory.tsx +51 -46
  87. package/src/components/form/FormRenderer.test.tsx +145 -0
  88. package/src/components/form/FormRenderer.tsx +52 -3
  89. package/src/context/ActionContext.tsx +213 -0
  90. package/src/context/DndContext.tsx +260 -0
  91. package/src/context/NotificationContext.tsx +280 -0
  92. package/src/context/ThemeContext.tsx +325 -0
  93. package/src/context/__tests__/ActionContext.test.tsx +174 -0
  94. package/src/context/__tests__/DndContext.test.tsx +150 -0
  95. package/src/context/__tests__/NotificationContext.test.tsx +176 -0
  96. package/src/context/index.ts +4 -0
  97. package/src/hooks/__tests__/useActionRunner.test.ts +153 -0
  98. package/src/hooks/__tests__/useAnimation.test.ts +82 -0
  99. package/src/hooks/__tests__/useColumnSummary.test.ts +93 -0
  100. package/src/hooks/__tests__/useDensityMode.test.ts +81 -0
  101. package/src/hooks/__tests__/useDiscovery.test.tsx +149 -0
  102. package/src/hooks/__tests__/useDynamicApp.test.ts +161 -0
  103. package/src/hooks/__tests__/useETagCache.test.ts +153 -0
  104. package/src/hooks/__tests__/useExpression.test.ts +86 -0
  105. package/src/hooks/__tests__/useFocusTrap.test.ts +47 -0
  106. package/src/hooks/__tests__/useKeyboardShortcuts.test.ts +144 -0
  107. package/src/hooks/__tests__/usePerformanceBudget.test.ts +157 -0
  108. package/src/hooks/__tests__/useSchemaPersistence.test.ts +220 -0
  109. package/src/hooks/__tests__/useViewSharing.test.ts +121 -0
  110. package/src/hooks/__tests__/useViewTransition.test.ts +61 -0
  111. package/src/hooks/index.ts +22 -1
  112. package/src/hooks/useActionRunner.ts +49 -11
  113. package/src/hooks/useAnimation.ts +117 -0
  114. package/src/hooks/useClientNotifications.ts +251 -0
  115. package/src/hooks/useColumnSummary.ts +87 -0
  116. package/src/hooks/useCrudShortcuts.ts +126 -0
  117. package/src/hooks/useDensityMode.ts +102 -0
  118. package/src/hooks/useDiscovery.ts +141 -0
  119. package/src/hooks/useDynamicApp.ts +142 -0
  120. package/src/hooks/useETagCache.ts +404 -0
  121. package/src/hooks/useFocusManagement.ts +248 -0
  122. package/src/hooks/useFocusTrap.ts +138 -0
  123. package/src/hooks/useKeyboardShortcuts.ts +119 -0
  124. package/src/hooks/useNavigationOverlay.ts +180 -0
  125. package/src/hooks/useOffline.ts +301 -0
  126. package/src/hooks/usePageTransition.ts +187 -0
  127. package/src/hooks/usePageVariables.tsx +157 -0
  128. package/src/hooks/usePerformance.ts +246 -0
  129. package/src/hooks/usePerformanceBudget.ts +313 -0
  130. package/src/hooks/useReducedMotion.ts +53 -0
  131. package/src/hooks/useSchemaPersistence.ts +222 -0
  132. package/src/hooks/useViewData.ts +201 -0
  133. package/src/hooks/useViewSharing.ts +140 -0
  134. package/src/hooks/useViewTransition.ts +144 -0
  135. package/src/index.ts +21 -0
  136. package/src/utils/i18n.ts +10 -0
  137. package/tsconfig.json +1 -0
  138. package/tsconfig.tsbuildinfo +1 -1
  139. package/dist/__tests__/LazyPluginLoader.test.d.ts +0 -8
  140. package/dist/__tests__/LazyPluginLoader.test.js +0 -49
  141. package/dist/components/form/FormRenderer.test.d.ts +0 -8
  142. package/dist/components/form/FormRenderer.test.js +0 -534
  143. package/dist/index.test.d.ts +0 -8
  144. package/dist/index.test.js +0 -13
@@ -0,0 +1,306 @@
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
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ // ---------------------------------------------------------------------------
10
+ // Defaults
11
+ // ---------------------------------------------------------------------------
12
+ const DEFAULTS = {
13
+ enabled: true,
14
+ storage: 'memory',
15
+ storagePrefix: 'oui-etag',
16
+ maxEntries: 1000,
17
+ ttl: 3600000, // 1 hour
18
+ useServiceWorker: false,
19
+ serviceWorkerUrl: '/service-worker.js',
20
+ };
21
+ // ---------------------------------------------------------------------------
22
+ // LRU cache implementation (module-scoped, shared across instances)
23
+ // ---------------------------------------------------------------------------
24
+ const memoryCache = new Map();
25
+ /** Access order tracking for LRU eviction — most recently used at the end. */
26
+ const accessOrder = [];
27
+ function touchAccessOrder(url) {
28
+ const idx = accessOrder.indexOf(url);
29
+ if (idx !== -1)
30
+ accessOrder.splice(idx, 1);
31
+ accessOrder.push(url);
32
+ }
33
+ function evictLRU(maxEntries) {
34
+ while (memoryCache.size > maxEntries && accessOrder.length > 0) {
35
+ const oldest = accessOrder.shift();
36
+ if (oldest)
37
+ memoryCache.delete(oldest);
38
+ }
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // localStorage helpers (SSR-safe)
42
+ // ---------------------------------------------------------------------------
43
+ function loadFromLocalStorage(prefix) {
44
+ if (typeof localStorage === 'undefined')
45
+ return new Map();
46
+ const entries = new Map();
47
+ try {
48
+ const raw = localStorage.getItem(`${prefix}:index`);
49
+ if (!raw)
50
+ return entries;
51
+ const keys = JSON.parse(raw);
52
+ for (const key of keys) {
53
+ const item = localStorage.getItem(`${prefix}:${key}`);
54
+ if (item) {
55
+ entries.set(key, JSON.parse(item));
56
+ }
57
+ }
58
+ }
59
+ catch {
60
+ // corrupted storage — ignore
61
+ }
62
+ return entries;
63
+ }
64
+ function persistToLocalStorage(prefix, cache) {
65
+ if (typeof localStorage === 'undefined')
66
+ return;
67
+ try {
68
+ const keys = Array.from(cache.keys());
69
+ localStorage.setItem(`${prefix}:index`, JSON.stringify(keys));
70
+ for (const [key, entry] of cache) {
71
+ localStorage.setItem(`${prefix}:${key}`, JSON.stringify(entry));
72
+ }
73
+ }
74
+ catch {
75
+ // localStorage full or unavailable — silently ignore
76
+ }
77
+ }
78
+ function clearLocalStorage(prefix) {
79
+ if (typeof localStorage === 'undefined')
80
+ return;
81
+ try {
82
+ const raw = localStorage.getItem(`${prefix}:index`);
83
+ if (raw) {
84
+ const keys = JSON.parse(raw);
85
+ for (const key of keys) {
86
+ localStorage.removeItem(`${prefix}:${key}`);
87
+ }
88
+ }
89
+ localStorage.removeItem(`${prefix}:index`);
90
+ }
91
+ catch {
92
+ // ignore
93
+ }
94
+ }
95
+ function removeFromLocalStorage(prefix, url) {
96
+ if (typeof localStorage === 'undefined')
97
+ return;
98
+ try {
99
+ localStorage.removeItem(`${prefix}:${url}`);
100
+ const raw = localStorage.getItem(`${prefix}:index`);
101
+ if (raw) {
102
+ const keys = JSON.parse(raw).filter((k) => k !== url);
103
+ localStorage.setItem(`${prefix}:index`, JSON.stringify(keys));
104
+ }
105
+ }
106
+ catch {
107
+ // ignore
108
+ }
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // useETagCache hook
112
+ // ---------------------------------------------------------------------------
113
+ /**
114
+ * Hook for ETag-based HTTP caching with optional Service Worker support.
115
+ * Integrates with @objectstack/client ETag caching for efficient data fetching.
116
+ * When the server responds with 304 Not Modified, cached data is reused.
117
+ *
118
+ * @example
119
+ * ```tsx
120
+ * function UserProfile({ userId }: { userId: string }) {
121
+ * const cache = useETagCache({ storage: 'localStorage', ttl: 60_000 });
122
+ *
123
+ * useEffect(() => {
124
+ * cache.fetchWithETag<User>(`/api/users/${userId}`).then(({ data, fromCache }) => {
125
+ * console.log(fromCache ? 'Served from cache' : 'Fresh data');
126
+ * setUser(data);
127
+ * });
128
+ * }, [userId]);
129
+ *
130
+ * return <div>Cache entries: {cache.cacheSize}</div>;
131
+ * }
132
+ * ```
133
+ */
134
+ export function useETagCache(userConfig = {}) {
135
+ const enabled = userConfig.enabled ?? DEFAULTS.enabled;
136
+ const storage = userConfig.storage ?? DEFAULTS.storage;
137
+ const storagePrefix = userConfig.storagePrefix ?? DEFAULTS.storagePrefix;
138
+ const maxEntries = userConfig.maxEntries ?? DEFAULTS.maxEntries;
139
+ const ttl = userConfig.ttl ?? DEFAULTS.ttl;
140
+ const useSW = userConfig.useServiceWorker ?? DEFAULTS.useServiceWorker;
141
+ const swUrl = userConfig.serviceWorkerUrl ?? DEFAULTS.serviceWorkerUrl;
142
+ const [cacheSize, setCacheSize] = useState(0);
143
+ const [serviceWorkerActive, setServiceWorkerActive] = useState(false);
144
+ // Keep config in a ref so callbacks always see latest values
145
+ const configRef = useRef({ enabled, storage, storagePrefix, maxEntries, ttl });
146
+ configRef.current = { enabled, storage, storagePrefix, maxEntries, ttl };
147
+ // Hydrate memory cache from localStorage on mount
148
+ useEffect(() => {
149
+ if (storage === 'localStorage') {
150
+ const persisted = loadFromLocalStorage(storagePrefix);
151
+ for (const [key, entry] of persisted) {
152
+ if (!memoryCache.has(key)) {
153
+ memoryCache.set(key, entry);
154
+ touchAccessOrder(key);
155
+ }
156
+ }
157
+ evictLRU(maxEntries);
158
+ setCacheSize(memoryCache.size);
159
+ }
160
+ // Only run on mount
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ }, []);
163
+ // Auto-register Service Worker when enabled
164
+ useEffect(() => {
165
+ if (!useSW)
166
+ return;
167
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator))
168
+ return;
169
+ navigator.serviceWorker
170
+ .register(swUrl, { scope: '/' })
171
+ .then((registration) => {
172
+ const worker = registration.active ?? registration.installing ?? registration.waiting;
173
+ setServiceWorkerActive(worker?.state === 'activated');
174
+ registration.onupdatefound = () => {
175
+ const installing = registration.installing;
176
+ if (!installing)
177
+ return;
178
+ installing.onstatechange = () => {
179
+ if (installing.state === 'activated') {
180
+ setServiceWorkerActive(true);
181
+ }
182
+ };
183
+ };
184
+ })
185
+ .catch(() => {
186
+ setServiceWorkerActive(false);
187
+ });
188
+ }, [useSW, swUrl]);
189
+ // ----- Helpers that read/write the shared cache -----
190
+ const isExpired = useCallback((entry) => {
191
+ return Date.now() - entry.timestamp > configRef.current.ttl;
192
+ }, []);
193
+ const setEntry = useCallback((url, entry) => {
194
+ memoryCache.set(url, entry);
195
+ touchAccessOrder(url);
196
+ evictLRU(configRef.current.maxEntries);
197
+ if (configRef.current.storage === 'localStorage') {
198
+ persistToLocalStorage(configRef.current.storagePrefix, memoryCache);
199
+ }
200
+ setCacheSize(memoryCache.size);
201
+ }, []);
202
+ const removeEntry = useCallback((url) => {
203
+ memoryCache.delete(url);
204
+ const idx = accessOrder.indexOf(url);
205
+ if (idx !== -1)
206
+ accessOrder.splice(idx, 1);
207
+ if (configRef.current.storage === 'localStorage') {
208
+ removeFromLocalStorage(configRef.current.storagePrefix, url);
209
+ }
210
+ setCacheSize(memoryCache.size);
211
+ }, []);
212
+ // ----- Public API -----
213
+ const fetchWithETag = useCallback(async (url, options) => {
214
+ // Validate URL: only allow http(s) or relative URLs
215
+ if (url.includes('://')) {
216
+ try {
217
+ const parsed = new URL(url);
218
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
219
+ throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
220
+ }
221
+ }
222
+ catch (e) {
223
+ if (e instanceof Error && e.message.startsWith('Unsupported'))
224
+ throw e;
225
+ throw new Error('Invalid URL');
226
+ }
227
+ }
228
+ if (!configRef.current.enabled) {
229
+ const res = await fetch(url, options);
230
+ const data = (await res.json());
231
+ return { data, fromCache: false, etag: res.headers.get('etag') ?? undefined };
232
+ }
233
+ const cached = memoryCache.get(url);
234
+ const headers = new Headers(options?.headers);
235
+ if (cached && !isExpired(cached)) {
236
+ if (cached.etag) {
237
+ headers.set('If-None-Match', cached.etag);
238
+ }
239
+ if (cached.lastModified) {
240
+ headers.set('If-Modified-Since', cached.lastModified);
241
+ }
242
+ }
243
+ const res = await fetch(url, { ...options, headers });
244
+ // 304 Not Modified — serve from cache
245
+ if (res.status === 304 && cached) {
246
+ touchAccessOrder(url);
247
+ return { data: cached.data, fromCache: true, etag: cached.etag };
248
+ }
249
+ const data = (await res.json());
250
+ const etag = res.headers.get('etag') ?? undefined;
251
+ const lastModified = res.headers.get('last-modified') ?? undefined;
252
+ if (etag || lastModified) {
253
+ setEntry(url, {
254
+ data,
255
+ etag: etag ?? '',
256
+ url,
257
+ timestamp: Date.now(),
258
+ lastModified,
259
+ });
260
+ }
261
+ return { data, fromCache: false, etag };
262
+ }, [isExpired, setEntry]);
263
+ const invalidate = useCallback((url) => {
264
+ removeEntry(url);
265
+ }, [removeEntry]);
266
+ const invalidatePattern = useCallback((pattern) => {
267
+ const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
268
+ const keysToRemove = Array.from(memoryCache.keys()).filter((key) => regex.test(key));
269
+ for (const key of keysToRemove) {
270
+ removeEntry(key);
271
+ }
272
+ }, [removeEntry]);
273
+ const clearCache = useCallback(() => {
274
+ memoryCache.clear();
275
+ accessOrder.length = 0;
276
+ if (configRef.current.storage === 'localStorage') {
277
+ clearLocalStorage(configRef.current.storagePrefix);
278
+ }
279
+ setCacheSize(0);
280
+ }, []);
281
+ const registerSW = useCallback(async () => {
282
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
283
+ return false;
284
+ }
285
+ try {
286
+ const registration = await navigator.serviceWorker.register(swUrl, { scope: '/' });
287
+ const worker = registration.active ?? registration.installing ?? registration.waiting;
288
+ const active = worker?.state === 'activated';
289
+ setServiceWorkerActive(active);
290
+ return active;
291
+ }
292
+ catch {
293
+ setServiceWorkerActive(false);
294
+ return false;
295
+ }
296
+ }, [swUrl]);
297
+ return useMemo(() => ({
298
+ fetchWithETag,
299
+ invalidate,
300
+ invalidatePattern,
301
+ clearCache,
302
+ cacheSize,
303
+ serviceWorkerActive,
304
+ registerServiceWorker: registerSW,
305
+ }), [fetchWithETag, invalidate, invalidatePattern, clearCache, cacheSize, serviceWorkerActive, registerSW]);
306
+ }
@@ -0,0 +1,89 @@
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
+ export interface FocusReturnOptions {
9
+ /** Whether focus restoration is enabled. @default true */
10
+ enabled?: boolean;
11
+ /** Whether to focus the previously active element on unmount. @default true */
12
+ restoreOnUnmount?: boolean;
13
+ }
14
+ /**
15
+ * Hook that captures the currently focused element on mount and restores
16
+ * focus to it when the component unmounts. Useful for modals, drawers,
17
+ * and overlays that temporarily steal focus.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * function Drawer({ onClose }: { onClose: () => void }) {
22
+ * const { returnFocus } = useFocusReturn();
23
+ *
24
+ * const handleClose = () => {
25
+ * returnFocus();
26
+ * onClose();
27
+ * };
28
+ *
29
+ * return <aside>...</aside>;
30
+ * }
31
+ * ```
32
+ */
33
+ export declare function useFocusReturn(options?: FocusReturnOptions): {
34
+ /** Manually restore focus to the previously focused element. */
35
+ returnFocus: () => void;
36
+ };
37
+ export type RovingOrientation = 'horizontal' | 'vertical' | 'both';
38
+ export interface RovingFocusOptions {
39
+ /** Whether roving focus is enabled. @default true */
40
+ enabled?: boolean;
41
+ /** Navigation orientation. @default 'vertical' */
42
+ orientation?: RovingOrientation;
43
+ /** Whether focus wraps from last to first item and vice-versa. @default true */
44
+ loop?: boolean;
45
+ /** Callback fired when the focused index changes. */
46
+ onFocusChange?: (index: number) => void;
47
+ }
48
+ export interface RovingFocusReturn<T extends HTMLElement> {
49
+ /** Ref to attach to the container element. */
50
+ containerRef: React.RefObject<T | null>;
51
+ /** The index of the currently focused item (-1 if none). */
52
+ focusedIndex: number;
53
+ /** Programmatically focus a specific item by index. */
54
+ setFocusedIndex: (index: number) => void;
55
+ }
56
+ /**
57
+ * Hook that implements roving tabindex / arrow-key navigation for a list of
58
+ * focusable items inside a container (sidebar items, tab lists, menu items).
59
+ *
60
+ * Only the currently active item participates in the tab order (tabindex 0);
61
+ * all other items are removed from the tab order (tabindex -1). Arrow keys
62
+ * move focus between items.
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * function SidebarNav({ items }: { items: string[] }) {
67
+ * const { containerRef, focusedIndex } = useRovingFocus<HTMLUListElement>({
68
+ * orientation: 'vertical',
69
+ * loop: true,
70
+ * });
71
+ *
72
+ * return (
73
+ * <ul ref={containerRef} role="tablist">
74
+ * {items.map((item, i) => (
75
+ * <li
76
+ * key={item}
77
+ * role="tab"
78
+ * tabIndex={i === focusedIndex ? 0 : -1}
79
+ * data-roving-item
80
+ * >
81
+ * {item}
82
+ * </li>
83
+ * ))}
84
+ * </ul>
85
+ * );
86
+ * }
87
+ * ```
88
+ */
89
+ export declare function useRovingFocus<T extends HTMLElement = HTMLDivElement>(options?: RovingFocusOptions): RovingFocusReturn<T>;
@@ -0,0 +1,188 @@
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
+ import { useEffect, useRef, useCallback, useState } from 'react';
9
+ /**
10
+ * Hook that captures the currently focused element on mount and restores
11
+ * focus to it when the component unmounts. Useful for modals, drawers,
12
+ * and overlays that temporarily steal focus.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * function Drawer({ onClose }: { onClose: () => void }) {
17
+ * const { returnFocus } = useFocusReturn();
18
+ *
19
+ * const handleClose = () => {
20
+ * returnFocus();
21
+ * onClose();
22
+ * };
23
+ *
24
+ * return <aside>...</aside>;
25
+ * }
26
+ * ```
27
+ */
28
+ export function useFocusReturn(options = {}) {
29
+ const { enabled = true, restoreOnUnmount = true } = options;
30
+ const previousFocusRef = useRef(null);
31
+ // Capture focused element on mount
32
+ useEffect(() => {
33
+ if (!enabled)
34
+ return;
35
+ previousFocusRef.current = document.activeElement;
36
+ }, [enabled]);
37
+ const returnFocus = useCallback(() => {
38
+ if (!enabled)
39
+ return;
40
+ const el = previousFocusRef.current;
41
+ if (el && typeof el.focus === 'function' && document.body.contains(el)) {
42
+ el.focus();
43
+ }
44
+ }, [enabled]);
45
+ // Restore on unmount
46
+ useEffect(() => {
47
+ if (!enabled || !restoreOnUnmount)
48
+ return;
49
+ return () => {
50
+ const el = previousFocusRef.current;
51
+ if (el && typeof el.focus === 'function' && document.body.contains(el)) {
52
+ el.focus();
53
+ }
54
+ };
55
+ // Only run cleanup on unmount — intentionally stable deps
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, []);
58
+ return { returnFocus };
59
+ }
60
+ /**
61
+ * Hook that implements roving tabindex / arrow-key navigation for a list of
62
+ * focusable items inside a container (sidebar items, tab lists, menu items).
63
+ *
64
+ * Only the currently active item participates in the tab order (tabindex 0);
65
+ * all other items are removed from the tab order (tabindex -1). Arrow keys
66
+ * move focus between items.
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * function SidebarNav({ items }: { items: string[] }) {
71
+ * const { containerRef, focusedIndex } = useRovingFocus<HTMLUListElement>({
72
+ * orientation: 'vertical',
73
+ * loop: true,
74
+ * });
75
+ *
76
+ * return (
77
+ * <ul ref={containerRef} role="tablist">
78
+ * {items.map((item, i) => (
79
+ * <li
80
+ * key={item}
81
+ * role="tab"
82
+ * tabIndex={i === focusedIndex ? 0 : -1}
83
+ * data-roving-item
84
+ * >
85
+ * {item}
86
+ * </li>
87
+ * ))}
88
+ * </ul>
89
+ * );
90
+ * }
91
+ * ```
92
+ */
93
+ export function useRovingFocus(options = {}) {
94
+ const { enabled = true, orientation = 'vertical', loop = true, onFocusChange, } = options;
95
+ const containerRef = useRef(null);
96
+ const [focusedIndex, setFocusedIndexState] = useState(-1);
97
+ const onFocusChangeRef = useRef(onFocusChange);
98
+ useEffect(() => {
99
+ onFocusChangeRef.current = onFocusChange;
100
+ }, [onFocusChange]);
101
+ const getItems = useCallback(() => {
102
+ if (!containerRef.current)
103
+ return [];
104
+ return Array.from(containerRef.current.querySelectorAll('[data-roving-item]')).filter((el) => {
105
+ // Exclude hidden or disabled elements
106
+ if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') {
107
+ return false;
108
+ }
109
+ return el.offsetParent !== null; // visible check
110
+ });
111
+ }, []);
112
+ const setFocusedIndex = useCallback((index) => {
113
+ const items = getItems();
114
+ if (items.length === 0)
115
+ return;
116
+ const clamped = Math.max(0, Math.min(index, items.length - 1));
117
+ setFocusedIndexState(clamped);
118
+ items[clamped]?.focus();
119
+ onFocusChangeRef.current?.(clamped);
120
+ }, [getItems]);
121
+ useEffect(() => {
122
+ if (!enabled || !containerRef.current)
123
+ return;
124
+ const handleKeyDown = (event) => {
125
+ const items = getItems();
126
+ if (items.length === 0)
127
+ return;
128
+ const currentIndex = items.findIndex((el) => el === document.activeElement);
129
+ if (currentIndex === -1)
130
+ return;
131
+ const prevKeys = orientation === 'horizontal' ? ['ArrowLeft'] :
132
+ orientation === 'vertical' ? ['ArrowUp'] :
133
+ ['ArrowUp', 'ArrowLeft'];
134
+ const nextKeys = orientation === 'horizontal' ? ['ArrowRight'] :
135
+ orientation === 'vertical' ? ['ArrowDown'] :
136
+ ['ArrowDown', 'ArrowRight'];
137
+ let nextIndex = null;
138
+ if (prevKeys.includes(event.key)) {
139
+ event.preventDefault();
140
+ if (currentIndex > 0) {
141
+ nextIndex = currentIndex - 1;
142
+ }
143
+ else if (loop) {
144
+ nextIndex = items.length - 1;
145
+ }
146
+ }
147
+ else if (nextKeys.includes(event.key)) {
148
+ event.preventDefault();
149
+ if (currentIndex < items.length - 1) {
150
+ nextIndex = currentIndex + 1;
151
+ }
152
+ else if (loop) {
153
+ nextIndex = 0;
154
+ }
155
+ }
156
+ else if (event.key === 'Home') {
157
+ event.preventDefault();
158
+ nextIndex = 0;
159
+ }
160
+ else if (event.key === 'End') {
161
+ event.preventDefault();
162
+ nextIndex = items.length - 1;
163
+ }
164
+ if (nextIndex !== null) {
165
+ setFocusedIndexState(nextIndex);
166
+ items[nextIndex].focus();
167
+ onFocusChangeRef.current?.(nextIndex);
168
+ }
169
+ };
170
+ // When an item receives focus (e.g. via click), sync the index
171
+ const handleFocusIn = (event) => {
172
+ const items = getItems();
173
+ const index = items.findIndex((el) => el === event.target);
174
+ if (index !== -1) {
175
+ setFocusedIndexState(index);
176
+ onFocusChangeRef.current?.(index);
177
+ }
178
+ };
179
+ const container = containerRef.current;
180
+ container.addEventListener('keydown', handleKeyDown);
181
+ container.addEventListener('focusin', handleFocusIn);
182
+ return () => {
183
+ container.removeEventListener('keydown', handleKeyDown);
184
+ container.removeEventListener('focusin', handleFocusIn);
185
+ };
186
+ }, [enabled, orientation, loop, getItems]);
187
+ return { containerRef, focusedIndex, setFocusedIndex };
188
+ }
@@ -0,0 +1,41 @@
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
+ export interface FocusTrapOptions {
9
+ /** Whether the focus trap is currently active */
10
+ enabled?: boolean;
11
+ /** Whether to auto-focus the first focusable element on mount */
12
+ autoFocus?: boolean;
13
+ /** Whether to restore focus to the previously focused element on unmount */
14
+ restoreFocus?: boolean;
15
+ /** Whether to release the trap on Escape key */
16
+ escapeDeactivates?: boolean;
17
+ /** Callback when Escape is pressed (if escapeDeactivates is true) */
18
+ onEscape?: () => void;
19
+ /** Initial element to focus (CSS selector) */
20
+ initialFocus?: string;
21
+ }
22
+ /**
23
+ * Hook that traps focus within a container element.
24
+ * Implements FocusTrapConfigSchema from @objectstack/spec v2.0.7.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * function Modal({ onClose }: { onClose: () => void }) {
29
+ * const trapRef = useFocusTrap({
30
+ * enabled: true,
31
+ * autoFocus: true,
32
+ * restoreFocus: true,
33
+ * escapeDeactivates: true,
34
+ * onEscape: onClose,
35
+ * });
36
+ *
37
+ * return <div ref={trapRef}>...</div>;
38
+ * }
39
+ * ```
40
+ */
41
+ export declare function useFocusTrap<T extends HTMLElement = HTMLDivElement>(options?: FocusTrapOptions): React.RefObject<T | null>;