@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,416 @@
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 { describe, it, expect, vi, beforeEach } from 'vitest';
10
+ import { renderHook, act } from '@testing-library/react';
11
+ import { useNavigationOverlay } from '../hooks/useNavigationOverlay';
12
+ import type { NavigationConfig } from '../hooks/useNavigationOverlay';
13
+
14
+ // Mock window.open
15
+ const mockWindowOpen = vi.fn();
16
+ Object.defineProperty(window, 'open', { value: mockWindowOpen, writable: true });
17
+
18
+ describe('useNavigationOverlay', () => {
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ });
22
+
23
+ // ============================================================
24
+ // Default behavior (no navigation config)
25
+ // ============================================================
26
+
27
+ describe('default behavior (no config)', () => {
28
+ it('should call onNavigate with record ID when no navigation config', () => {
29
+ const onNavigate = vi.fn();
30
+ const { result } = renderHook(() =>
31
+ useNavigationOverlay({
32
+ objectName: 'contacts',
33
+ onNavigate,
34
+ })
35
+ );
36
+
37
+ act(() => {
38
+ result.current.handleClick({ _id: '123', name: 'Test' });
39
+ });
40
+
41
+ expect(onNavigate).toHaveBeenCalledWith('123', 'view');
42
+ expect(result.current.isOpen).toBe(false);
43
+ });
44
+
45
+ it('should use id field when _id is not present', () => {
46
+ const onNavigate = vi.fn();
47
+ const { result } = renderHook(() =>
48
+ useNavigationOverlay({
49
+ objectName: 'contacts',
50
+ onNavigate,
51
+ })
52
+ );
53
+
54
+ act(() => {
55
+ result.current.handleClick({ id: '456', name: 'Test' });
56
+ });
57
+
58
+ expect(onNavigate).toHaveBeenCalledWith('456', 'view');
59
+ });
60
+
61
+ it('should do nothing when no onNavigate and no navigation config', () => {
62
+ const { result } = renderHook(() =>
63
+ useNavigationOverlay({ objectName: 'contacts' })
64
+ );
65
+
66
+ act(() => {
67
+ result.current.handleClick({ _id: '123' });
68
+ });
69
+
70
+ expect(result.current.isOpen).toBe(false);
71
+ });
72
+ });
73
+
74
+ // ============================================================
75
+ // onRowClick priority
76
+ // ============================================================
77
+
78
+ describe('onRowClick priority', () => {
79
+ it('should call onRowClick instead of navigation when provided', () => {
80
+ const onRowClick = vi.fn();
81
+ const onNavigate = vi.fn();
82
+ const { result } = renderHook(() =>
83
+ useNavigationOverlay({
84
+ navigation: { mode: 'drawer' },
85
+ objectName: 'contacts',
86
+ onNavigate,
87
+ onRowClick,
88
+ })
89
+ );
90
+
91
+ act(() => {
92
+ result.current.handleClick({ _id: '123' });
93
+ });
94
+
95
+ expect(onRowClick).toHaveBeenCalledWith({ _id: '123' });
96
+ expect(onNavigate).not.toHaveBeenCalled();
97
+ expect(result.current.isOpen).toBe(false);
98
+ });
99
+ });
100
+
101
+ // ============================================================
102
+ // mode: 'none'
103
+ // ============================================================
104
+
105
+ describe('mode: none', () => {
106
+ it('should do nothing when mode is none', () => {
107
+ const onNavigate = vi.fn();
108
+ const { result } = renderHook(() =>
109
+ useNavigationOverlay({
110
+ navigation: { mode: 'none' },
111
+ objectName: 'contacts',
112
+ onNavigate,
113
+ })
114
+ );
115
+
116
+ act(() => {
117
+ result.current.handleClick({ _id: '123' });
118
+ });
119
+
120
+ expect(onNavigate).not.toHaveBeenCalled();
121
+ expect(result.current.isOpen).toBe(false);
122
+ });
123
+
124
+ it('should do nothing when preventNavigation is true', () => {
125
+ const onNavigate = vi.fn();
126
+ const { result } = renderHook(() =>
127
+ useNavigationOverlay({
128
+ navigation: { mode: 'page', preventNavigation: true },
129
+ objectName: 'contacts',
130
+ onNavigate,
131
+ })
132
+ );
133
+
134
+ act(() => {
135
+ result.current.handleClick({ _id: '123' });
136
+ });
137
+
138
+ expect(onNavigate).not.toHaveBeenCalled();
139
+ });
140
+ });
141
+
142
+ // ============================================================
143
+ // mode: 'page'
144
+ // ============================================================
145
+
146
+ describe('mode: page', () => {
147
+ it('should call onNavigate for page mode', () => {
148
+ const onNavigate = vi.fn();
149
+ const { result } = renderHook(() =>
150
+ useNavigationOverlay({
151
+ navigation: { mode: 'page' },
152
+ objectName: 'contacts',
153
+ onNavigate,
154
+ })
155
+ );
156
+
157
+ act(() => {
158
+ result.current.handleClick({ _id: 'abc' });
159
+ });
160
+
161
+ expect(onNavigate).toHaveBeenCalledWith('abc', 'view');
162
+ expect(result.current.isOpen).toBe(false);
163
+ });
164
+ });
165
+
166
+ // ============================================================
167
+ // mode: 'new_window'
168
+ // ============================================================
169
+
170
+ describe('mode: new_window', () => {
171
+ it('should open a new window for new_window mode', () => {
172
+ const { result } = renderHook(() =>
173
+ useNavigationOverlay({
174
+ navigation: { mode: 'new_window' },
175
+ objectName: 'contacts',
176
+ })
177
+ );
178
+
179
+ act(() => {
180
+ result.current.handleClick({ _id: '123' });
181
+ });
182
+
183
+ expect(mockWindowOpen).toHaveBeenCalledWith('/contacts/123', '_blank');
184
+ expect(result.current.isOpen).toBe(false);
185
+ });
186
+
187
+ it('should open a new window when openNewTab is true regardless of mode', () => {
188
+ const { result } = renderHook(() =>
189
+ useNavigationOverlay({
190
+ navigation: { mode: 'page', openNewTab: true },
191
+ objectName: 'users',
192
+ })
193
+ );
194
+
195
+ act(() => {
196
+ result.current.handleClick({ id: '456' });
197
+ });
198
+
199
+ expect(mockWindowOpen).toHaveBeenCalledWith('/users/456', '_blank');
200
+ });
201
+ });
202
+
203
+ // ============================================================
204
+ // mode: 'drawer'
205
+ // ============================================================
206
+
207
+ describe('mode: drawer', () => {
208
+ it('should open overlay for drawer mode', () => {
209
+ const { result } = renderHook(() =>
210
+ useNavigationOverlay({
211
+ navigation: { mode: 'drawer' },
212
+ objectName: 'contacts',
213
+ })
214
+ );
215
+
216
+ expect(result.current.isOpen).toBe(false);
217
+ expect(result.current.isOverlay).toBe(true);
218
+ expect(result.current.mode).toBe('drawer');
219
+
220
+ act(() => {
221
+ result.current.handleClick({ _id: '123', name: 'John' });
222
+ });
223
+
224
+ expect(result.current.isOpen).toBe(true);
225
+ expect(result.current.selectedRecord).toEqual({ _id: '123', name: 'John' });
226
+ });
227
+
228
+ it('should close the overlay', () => {
229
+ const { result } = renderHook(() =>
230
+ useNavigationOverlay({
231
+ navigation: { mode: 'drawer' },
232
+ objectName: 'contacts',
233
+ })
234
+ );
235
+
236
+ act(() => {
237
+ result.current.handleClick({ _id: '123' });
238
+ });
239
+ expect(result.current.isOpen).toBe(true);
240
+
241
+ act(() => {
242
+ result.current.close();
243
+ });
244
+ expect(result.current.isOpen).toBe(false);
245
+ expect(result.current.selectedRecord).toBe(null);
246
+ });
247
+
248
+ it('should expose width from config', () => {
249
+ const { result } = renderHook(() =>
250
+ useNavigationOverlay({
251
+ navigation: { mode: 'drawer', width: '600px' },
252
+ objectName: 'contacts',
253
+ })
254
+ );
255
+
256
+ expect(result.current.width).toBe('600px');
257
+ });
258
+ });
259
+
260
+ // ============================================================
261
+ // mode: 'modal'
262
+ // ============================================================
263
+
264
+ describe('mode: modal', () => {
265
+ it('should open overlay for modal mode', () => {
266
+ const { result } = renderHook(() =>
267
+ useNavigationOverlay({
268
+ navigation: { mode: 'modal' },
269
+ objectName: 'contacts',
270
+ })
271
+ );
272
+
273
+ expect(result.current.isOverlay).toBe(true);
274
+ expect(result.current.mode).toBe('modal');
275
+
276
+ act(() => {
277
+ result.current.handleClick({ _id: '123' });
278
+ });
279
+
280
+ expect(result.current.isOpen).toBe(true);
281
+ });
282
+
283
+ it('should support numeric width', () => {
284
+ const { result } = renderHook(() =>
285
+ useNavigationOverlay({
286
+ navigation: { mode: 'modal', width: 800 },
287
+ objectName: 'contacts',
288
+ })
289
+ );
290
+
291
+ expect(result.current.width).toBe(800);
292
+ });
293
+ });
294
+
295
+ // ============================================================
296
+ // mode: 'split'
297
+ // ============================================================
298
+
299
+ describe('mode: split', () => {
300
+ it('should open overlay for split mode', () => {
301
+ const { result } = renderHook(() =>
302
+ useNavigationOverlay({
303
+ navigation: { mode: 'split' },
304
+ objectName: 'contacts',
305
+ })
306
+ );
307
+
308
+ expect(result.current.isOverlay).toBe(true);
309
+ expect(result.current.mode).toBe('split');
310
+
311
+ act(() => {
312
+ result.current.handleClick({ _id: '123' });
313
+ });
314
+
315
+ expect(result.current.isOpen).toBe(true);
316
+ });
317
+ });
318
+
319
+ // ============================================================
320
+ // mode: 'popover'
321
+ // ============================================================
322
+
323
+ describe('mode: popover', () => {
324
+ it('should open overlay for popover mode', () => {
325
+ const { result } = renderHook(() =>
326
+ useNavigationOverlay({
327
+ navigation: { mode: 'popover' },
328
+ objectName: 'contacts',
329
+ })
330
+ );
331
+
332
+ expect(result.current.isOverlay).toBe(true);
333
+ expect(result.current.mode).toBe('popover');
334
+
335
+ act(() => {
336
+ result.current.handleClick({ _id: '123' });
337
+ });
338
+
339
+ expect(result.current.isOpen).toBe(true);
340
+ });
341
+ });
342
+
343
+ // ============================================================
344
+ // open / close helpers
345
+ // ============================================================
346
+
347
+ describe('open/close helpers', () => {
348
+ it('should support programmatic open', () => {
349
+ const { result } = renderHook(() =>
350
+ useNavigationOverlay({
351
+ navigation: { mode: 'drawer' },
352
+ objectName: 'contacts',
353
+ })
354
+ );
355
+
356
+ act(() => {
357
+ result.current.open({ _id: 'test', name: 'Direct open' });
358
+ });
359
+
360
+ expect(result.current.isOpen).toBe(true);
361
+ expect(result.current.selectedRecord).toEqual({ _id: 'test', name: 'Direct open' });
362
+ });
363
+
364
+ it('should support setIsOpen for controlled overlay', () => {
365
+ const { result } = renderHook(() =>
366
+ useNavigationOverlay({
367
+ navigation: { mode: 'modal' },
368
+ objectName: 'contacts',
369
+ })
370
+ );
371
+
372
+ act(() => {
373
+ result.current.open({ _id: '1' });
374
+ });
375
+ expect(result.current.isOpen).toBe(true);
376
+
377
+ act(() => {
378
+ result.current.setIsOpen(false);
379
+ });
380
+ expect(result.current.isOpen).toBe(false);
381
+ });
382
+ });
383
+
384
+ // ============================================================
385
+ // isOverlay flag
386
+ // ============================================================
387
+
388
+ describe('isOverlay flag', () => {
389
+ const overlayModes: NavigationConfig['mode'][] = ['drawer', 'modal', 'split', 'popover'];
390
+ const nonOverlayModes: NavigationConfig['mode'][] = ['page', 'new_window', 'none'];
391
+
392
+ overlayModes.forEach((mode) => {
393
+ it(`should be true for mode: ${mode}`, () => {
394
+ const { result } = renderHook(() =>
395
+ useNavigationOverlay({
396
+ navigation: { mode },
397
+ objectName: 'test',
398
+ })
399
+ );
400
+ expect(result.current.isOverlay).toBe(true);
401
+ });
402
+ });
403
+
404
+ nonOverlayModes.forEach((mode) => {
405
+ it(`should be false for mode: ${mode}`, () => {
406
+ const { result } = renderHook(() =>
407
+ useNavigationOverlay({
408
+ navigation: { mode },
409
+ objectName: 'test',
410
+ })
411
+ );
412
+ expect(result.current.isOverlay).toBe(false);
413
+ });
414
+ });
415
+ });
416
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * ObjectUI — useOffline Tests
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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import { renderHook, act } from '@testing-library/react';
11
+ import { useOffline } from '../hooks/useOffline';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const QUEUE_KEY = 'objectui-offline-queue';
18
+
19
+ function mockOnlineStatus(online: boolean) {
20
+ Object.defineProperty(navigator, 'onLine', {
21
+ value: online,
22
+ configurable: true,
23
+ writable: true,
24
+ });
25
+ }
26
+
27
+ function triggerOnline() {
28
+ mockOnlineStatus(true);
29
+ window.dispatchEvent(new Event('online'));
30
+ }
31
+
32
+ function triggerOffline() {
33
+ mockOnlineStatus(false);
34
+ window.dispatchEvent(new Event('offline'));
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Tests
39
+ // ---------------------------------------------------------------------------
40
+
41
+ describe('useOffline', () => {
42
+ beforeEach(() => {
43
+ mockOnlineStatus(true);
44
+ localStorage.removeItem(QUEUE_KEY);
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ });
50
+
51
+ it('should default to online with empty queue', () => {
52
+ const { result } = renderHook(() => useOffline());
53
+ expect(result.current.isOnline).toBe(true);
54
+ expect(result.current.enabled).toBe(true);
55
+ expect(result.current.syncState).toBe('idle');
56
+ expect(result.current.pendingCount).toBe(0);
57
+ expect(result.current.showIndicator).toBe(false);
58
+ expect(result.current.strategy).toBe('network_first');
59
+ });
60
+
61
+ it('should detect offline status', () => {
62
+ const { result } = renderHook(() => useOffline());
63
+ expect(result.current.isOnline).toBe(true);
64
+
65
+ act(() => {
66
+ triggerOffline();
67
+ });
68
+
69
+ expect(result.current.isOnline).toBe(false);
70
+ expect(result.current.showIndicator).toBe(true);
71
+ });
72
+
73
+ it('should detect coming back online', () => {
74
+ mockOnlineStatus(false);
75
+ const { result } = renderHook(() => useOffline());
76
+ expect(result.current.isOnline).toBe(false);
77
+
78
+ act(() => {
79
+ triggerOnline();
80
+ });
81
+
82
+ expect(result.current.isOnline).toBe(true);
83
+ expect(result.current.showIndicator).toBe(false);
84
+ });
85
+
86
+ it('should queue mutations', () => {
87
+ const { result } = renderHook(() => useOffline());
88
+
89
+ act(() => {
90
+ result.current.queueMutation({
91
+ operation: 'create',
92
+ resource: 'users',
93
+ data: { name: 'Alice' },
94
+ });
95
+ });
96
+
97
+ expect(result.current.pendingCount).toBe(1);
98
+
99
+ act(() => {
100
+ result.current.queueMutation({
101
+ operation: 'update',
102
+ resource: 'users',
103
+ data: { id: '1', name: 'Bob' },
104
+ });
105
+ });
106
+
107
+ expect(result.current.pendingCount).toBe(2);
108
+ });
109
+
110
+ it('should clear the mutation queue', () => {
111
+ const { result } = renderHook(() => useOffline());
112
+
113
+ act(() => {
114
+ result.current.queueMutation({ operation: 'create', resource: 'users' });
115
+ result.current.queueMutation({ operation: 'delete', resource: 'users' });
116
+ });
117
+
118
+ expect(result.current.pendingCount).toBe(2);
119
+
120
+ act(() => {
121
+ result.current.clearQueue();
122
+ });
123
+
124
+ expect(result.current.pendingCount).toBe(0);
125
+ });
126
+
127
+ it('should enforce queueMaxSize', () => {
128
+ const { result } = renderHook(() => useOffline({ queueMaxSize: 2 }));
129
+
130
+ act(() => {
131
+ result.current.queueMutation({ operation: 'create', resource: 'a' });
132
+ result.current.queueMutation({ operation: 'create', resource: 'b' });
133
+ result.current.queueMutation({ operation: 'create', resource: 'c' });
134
+ });
135
+
136
+ expect(result.current.pendingCount).toBe(2);
137
+ });
138
+
139
+ it('should respect custom config values', () => {
140
+ const { result } = renderHook(() =>
141
+ useOffline({
142
+ strategy: 'cache_first',
143
+ offlineIndicator: false,
144
+ offlineMessage: 'Custom message',
145
+ }),
146
+ );
147
+
148
+ expect(result.current.strategy).toBe('cache_first');
149
+ expect(result.current.offlineMessage).toBe('Custom message');
150
+
151
+ act(() => {
152
+ triggerOffline();
153
+ });
154
+
155
+ // offlineIndicator is false, so showIndicator should be false even when offline
156
+ expect(result.current.showIndicator).toBe(false);
157
+ });
158
+
159
+ it('should not queue mutations when disabled', () => {
160
+ const { result } = renderHook(() => useOffline({ enabled: false }));
161
+
162
+ act(() => {
163
+ result.current.queueMutation({ operation: 'create', resource: 'users' });
164
+ });
165
+
166
+ expect(result.current.pendingCount).toBe(0);
167
+ expect(result.current.enabled).toBe(false);
168
+ });
169
+
170
+ it('should sync mutations', async () => {
171
+ const { result } = renderHook(() => useOffline());
172
+
173
+ act(() => {
174
+ result.current.queueMutation({ operation: 'create', resource: 'users' });
175
+ });
176
+
177
+ expect(result.current.pendingCount).toBe(1);
178
+
179
+ await act(async () => {
180
+ await result.current.sync();
181
+ });
182
+
183
+ expect(result.current.pendingCount).toBe(0);
184
+ expect(result.current.syncState).toBe('idle');
185
+ });
186
+
187
+ it('should persist queue to localStorage', () => {
188
+ const { result } = renderHook(() => useOffline());
189
+
190
+ act(() => {
191
+ result.current.queueMutation({ operation: 'create', resource: 'users' });
192
+ });
193
+
194
+ const stored = JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]');
195
+ expect(stored).toHaveLength(1);
196
+ expect(stored[0].resource).toBe('users');
197
+ });
198
+ });