@object-ui/react 3.0.2 → 3.1.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 (61) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/dist/SchemaRenderer.js +61 -2
  4. package/dist/context/SchemaRendererContext.d.ts +4 -1
  5. package/dist/context/SchemaRendererContext.js +6 -4
  6. package/dist/hooks/index.d.ts +3 -0
  7. package/dist/hooks/index.js +3 -0
  8. package/dist/hooks/useActionEngine.d.ts +27 -0
  9. package/dist/hooks/useActionEngine.js +39 -0
  10. package/dist/hooks/useDebugMode.d.ts +43 -0
  11. package/dist/hooks/useDebugMode.js +71 -0
  12. package/dist/hooks/useGlobalUndo.d.ts +35 -0
  13. package/dist/hooks/useGlobalUndo.js +86 -0
  14. package/dist/hooks/useNavigationOverlay.d.ts +2 -0
  15. package/dist/hooks/useNavigationOverlay.js +12 -5
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.js +4 -1
  18. package/dist/spec-bridge/SpecBridge.d.ts +26 -0
  19. package/dist/spec-bridge/SpecBridge.js +62 -0
  20. package/dist/spec-bridge/bridges/dashboard.d.ts +81 -0
  21. package/dist/spec-bridge/bridges/dashboard.js +121 -0
  22. package/dist/spec-bridge/bridges/form-view.d.ts +44 -0
  23. package/dist/spec-bridge/bridges/form-view.js +77 -0
  24. package/dist/spec-bridge/bridges/list-view.d.ts +82 -0
  25. package/dist/spec-bridge/bridges/list-view.js +154 -0
  26. package/dist/spec-bridge/bridges/page.d.ts +68 -0
  27. package/dist/spec-bridge/bridges/page.js +81 -0
  28. package/dist/spec-bridge/index.d.ts +13 -0
  29. package/dist/spec-bridge/index.js +12 -0
  30. package/dist/spec-bridge/types.d.ts +34 -0
  31. package/{src/index.test.ts → dist/spec-bridge/types.js} +1 -8
  32. package/package.json +7 -7
  33. package/src/SchemaRenderer.tsx +66 -0
  34. package/src/__tests__/LazyPluginLoader.test.tsx +1 -1
  35. package/src/__tests__/SchemaRenderer.debug.test.tsx +69 -0
  36. package/src/__tests__/SchemaRenderer.expressions.test.tsx +131 -0
  37. package/src/__tests__/useNavigationOverlay.test.ts +162 -0
  38. package/src/context/SchemaRendererContext.tsx +9 -4
  39. package/src/context/__tests__/useDataScope.test.tsx +91 -0
  40. package/src/hooks/__tests__/useActionEngine.test.ts +170 -0
  41. package/src/hooks/__tests__/useCrudShortcuts.test.ts +85 -0
  42. package/src/hooks/__tests__/useDebugMode.test.ts +83 -0
  43. package/src/hooks/__tests__/useFocusTrap.test.ts +247 -0
  44. package/src/hooks/__tests__/usePageTransition.test.ts +158 -0
  45. package/src/hooks/__tests__/useReducedMotion.test.ts +104 -0
  46. package/src/hooks/index.ts +3 -0
  47. package/src/hooks/useActionEngine.ts +84 -0
  48. package/src/hooks/useDebugMode.ts +95 -0
  49. package/src/hooks/useGlobalUndo.ts +98 -0
  50. package/src/hooks/useNavigationOverlay.ts +14 -5
  51. package/src/index.ts +6 -0
  52. package/src/spec-bridge/SpecBridge.ts +64 -0
  53. package/src/spec-bridge/__tests__/P1SpecBridge.test.ts +855 -0
  54. package/src/spec-bridge/__tests__/SpecBridge.test.ts +550 -0
  55. package/src/spec-bridge/bridges/dashboard.ts +180 -0
  56. package/src/spec-bridge/bridges/form-view.ts +109 -0
  57. package/src/spec-bridge/bridges/list-view.ts +187 -0
  58. package/src/spec-bridge/bridges/page.ts +136 -0
  59. package/src/spec-bridge/index.ts +14 -0
  60. package/src/spec-bridge/types.ts +38 -0
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tests for useDataScope hook — verifies correct scoping behavior.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { renderHook } from '@testing-library/react';
7
+ import React from 'react';
8
+ import { SchemaRendererProvider, useDataScope } from '../SchemaRendererContext';
9
+
10
+ describe('useDataScope', () => {
11
+ it('returns undefined when no path is provided', () => {
12
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
13
+ <SchemaRendererProvider dataSource={{ users: [1, 2, 3] }}>
14
+ {children}
15
+ </SchemaRendererProvider>
16
+ );
17
+
18
+ const { result } = renderHook(() => useDataScope(undefined), { wrapper });
19
+
20
+ expect(result.current).toBeUndefined();
21
+ });
22
+
23
+ it('returns undefined when path is empty string', () => {
24
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
25
+ <SchemaRendererProvider dataSource={{ users: [1, 2, 3] }}>
26
+ {children}
27
+ </SchemaRendererProvider>
28
+ );
29
+
30
+ const { result } = renderHook(() => useDataScope(''), { wrapper });
31
+
32
+ expect(result.current).toBeUndefined();
33
+ });
34
+
35
+ it('returns scoped data when a valid path is given', () => {
36
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
37
+ <SchemaRendererProvider dataSource={{ users: [{ name: 'Alice' }] }}>
38
+ {children}
39
+ </SchemaRendererProvider>
40
+ );
41
+
42
+ const { result } = renderHook(() => useDataScope('users'), { wrapper });
43
+
44
+ expect(result.current).toEqual([{ name: 'Alice' }]);
45
+ });
46
+
47
+ it('resolves nested paths', () => {
48
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
49
+ <SchemaRendererProvider dataSource={{ app: { settings: { theme: 'dark' } } }}>
50
+ {children}
51
+ </SchemaRendererProvider>
52
+ );
53
+
54
+ const { result } = renderHook(() => useDataScope('app.settings.theme'), { wrapper });
55
+
56
+ expect(result.current).toBe('dark');
57
+ });
58
+
59
+ it('returns undefined for non-existent path', () => {
60
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
61
+ <SchemaRendererProvider dataSource={{ users: [] }}>
62
+ {children}
63
+ </SchemaRendererProvider>
64
+ );
65
+
66
+ const { result } = renderHook(() => useDataScope('nonexistent'), { wrapper });
67
+
68
+ expect(result.current).toBeUndefined();
69
+ });
70
+
71
+ it('returns undefined when no SchemaRendererProvider is present', () => {
72
+ const { result } = renderHook(() => useDataScope('users'));
73
+
74
+ expect(result.current).toBeUndefined();
75
+ });
76
+
77
+ it('does not return the adapter/service object when no path is given', () => {
78
+ // Simulate the real scenario: dataSource is a service adapter with methods
79
+ const adapter = { find: () => {}, create: () => {}, update: () => {} };
80
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
81
+ <SchemaRendererProvider dataSource={adapter}>
82
+ {children}
83
+ </SchemaRendererProvider>
84
+ );
85
+
86
+ const { result } = renderHook(() => useDataScope(undefined), { wrapper });
87
+
88
+ // Should NOT return the adapter — that would prevent ObjectChart from fetching
89
+ expect(result.current).toBeUndefined();
90
+ });
91
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tests for useActionEngine hook
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { renderHook, act } from '@testing-library/react';
7
+ import { useActionEngine } from '../useActionEngine';
8
+
9
+ describe('useActionEngine', () => {
10
+ const sampleActions = [
11
+ {
12
+ name: 'mark_complete',
13
+ type: 'script',
14
+ execute: 'true',
15
+ locations: ['list_toolbar', 'record_header'],
16
+ bulkEnabled: true,
17
+ },
18
+ {
19
+ name: 'delete_record',
20
+ type: 'api',
21
+ endpoint: '/api/delete',
22
+ locations: ['record_more'],
23
+ },
24
+ {
25
+ name: 'view_details',
26
+ type: 'url',
27
+ target: '/details',
28
+ locations: ['list_item'],
29
+ },
30
+ {
31
+ name: 'global_search',
32
+ type: 'script',
33
+ execute: 'true',
34
+ locations: ['global_nav'],
35
+ shortcut: 'ctrl+k',
36
+ },
37
+ ];
38
+
39
+ describe('getActionsForLocation', () => {
40
+ it('returns actions for list_toolbar', () => {
41
+ const { result } = renderHook(() =>
42
+ useActionEngine({ actions: sampleActions }),
43
+ );
44
+
45
+ const actions = result.current.getActionsForLocation('list_toolbar');
46
+ expect(actions.length).toBe(1);
47
+ expect(actions[0].name).toBe('mark_complete');
48
+ });
49
+
50
+ it('returns actions for record_header', () => {
51
+ const { result } = renderHook(() =>
52
+ useActionEngine({ actions: sampleActions }),
53
+ );
54
+
55
+ const actions = result.current.getActionsForLocation('record_header');
56
+ expect(actions.length).toBe(1);
57
+ expect(actions[0].name).toBe('mark_complete');
58
+ });
59
+
60
+ it('returns actions for record_more', () => {
61
+ const { result } = renderHook(() =>
62
+ useActionEngine({ actions: sampleActions }),
63
+ );
64
+
65
+ const actions = result.current.getActionsForLocation('record_more');
66
+ expect(actions.length).toBe(1);
67
+ expect(actions[0].name).toBe('delete_record');
68
+ });
69
+
70
+ it('returns empty array for location with no actions', () => {
71
+ const { result } = renderHook(() =>
72
+ useActionEngine({ actions: sampleActions }),
73
+ );
74
+
75
+ const actions = result.current.getActionsForLocation('record_related');
76
+ expect(actions.length).toBe(0);
77
+ });
78
+ });
79
+
80
+ describe('getBulkActions', () => {
81
+ it('returns only bulk-enabled actions', () => {
82
+ const { result } = renderHook(() =>
83
+ useActionEngine({ actions: sampleActions }),
84
+ );
85
+
86
+ const bulkActions = result.current.getBulkActions();
87
+ expect(bulkActions.length).toBe(1);
88
+ expect(bulkActions[0].name).toBe('mark_complete');
89
+ });
90
+ });
91
+
92
+ describe('executeAction', () => {
93
+ it('executes a registered action by name', async () => {
94
+ const { result } = renderHook(() =>
95
+ useActionEngine({ actions: sampleActions }),
96
+ );
97
+
98
+ let actionResult: any;
99
+ await act(async () => {
100
+ actionResult = await result.current.executeAction('mark_complete');
101
+ });
102
+
103
+ expect(actionResult.success).toBe(true);
104
+ });
105
+
106
+ it('returns error for unregistered action', async () => {
107
+ const { result } = renderHook(() =>
108
+ useActionEngine({ actions: sampleActions }),
109
+ );
110
+
111
+ let actionResult: any;
112
+ await act(async () => {
113
+ actionResult = await result.current.executeAction('nonexistent');
114
+ });
115
+
116
+ expect(actionResult.success).toBe(false);
117
+ expect(actionResult.error).toContain('not found');
118
+ });
119
+ });
120
+
121
+ describe('handleShortcut', () => {
122
+ it('handles registered keyboard shortcut', async () => {
123
+ const { result } = renderHook(() =>
124
+ useActionEngine({ actions: sampleActions }),
125
+ );
126
+
127
+ let shortcutResult: any;
128
+ await act(async () => {
129
+ shortcutResult = await result.current.handleShortcut('ctrl+k');
130
+ });
131
+
132
+ expect(shortcutResult).not.toBeNull();
133
+ expect(shortcutResult.success).toBe(true);
134
+ });
135
+
136
+ it('returns null for unregistered shortcut', async () => {
137
+ const { result } = renderHook(() =>
138
+ useActionEngine({ actions: sampleActions }),
139
+ );
140
+
141
+ let shortcutResult: any;
142
+ await act(async () => {
143
+ shortcutResult = await result.current.handleShortcut('ctrl+z');
144
+ });
145
+
146
+ expect(shortcutResult).toBeNull();
147
+ });
148
+ });
149
+
150
+ describe('defaults', () => {
151
+ it('works with no options', () => {
152
+ const { result } = renderHook(() => useActionEngine());
153
+
154
+ expect(result.current.engine).toBeDefined();
155
+ expect(typeof result.current.getActionsForLocation).toBe('function');
156
+ expect(typeof result.current.getBulkActions).toBe('function');
157
+ expect(typeof result.current.executeAction).toBe('function');
158
+ expect(typeof result.current.handleShortcut).toBe('function');
159
+ });
160
+
161
+ it('works with empty actions array', () => {
162
+ const { result } = renderHook(() =>
163
+ useActionEngine({ actions: [] }),
164
+ );
165
+
166
+ const actions = result.current.getActionsForLocation('list_toolbar');
167
+ expect(actions).toEqual([]);
168
+ });
169
+ });
170
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tests for useCrudShortcuts hook
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest';
5
+ import { renderHook } from '@testing-library/react';
6
+ import { useCrudShortcuts } from '../useCrudShortcuts';
7
+
8
+ describe('useCrudShortcuts', () => {
9
+ it('renders without error with empty callbacks', () => {
10
+ expect(() => {
11
+ renderHook(() => useCrudShortcuts({}));
12
+ }).not.toThrow();
13
+ });
14
+
15
+ it('renders without error when disabled', () => {
16
+ expect(() => {
17
+ renderHook(() => useCrudShortcuts({}, false));
18
+ }).not.toThrow();
19
+ });
20
+
21
+ it('registers onCreate shortcut', () => {
22
+ const onCreate = vi.fn();
23
+ expect(() => {
24
+ renderHook(() => useCrudShortcuts({ onCreate }));
25
+ }).not.toThrow();
26
+ });
27
+
28
+ it('registers onEdit shortcut', () => {
29
+ const onEdit = vi.fn();
30
+ expect(() => {
31
+ renderHook(() => useCrudShortcuts({ onEdit }));
32
+ }).not.toThrow();
33
+ });
34
+
35
+ it('registers onDelete shortcut', () => {
36
+ const onDelete = vi.fn();
37
+ expect(() => {
38
+ renderHook(() => useCrudShortcuts({ onDelete }));
39
+ }).not.toThrow();
40
+ });
41
+
42
+ it('registers onSave shortcut', () => {
43
+ const onSave = vi.fn();
44
+ expect(() => {
45
+ renderHook(() => useCrudShortcuts({ onSave }));
46
+ }).not.toThrow();
47
+ });
48
+
49
+ it('registers onDuplicate shortcut', () => {
50
+ const onDuplicate = vi.fn();
51
+ expect(() => {
52
+ renderHook(() => useCrudShortcuts({ onDuplicate }));
53
+ }).not.toThrow();
54
+ });
55
+
56
+ it('registers onCancel shortcut', () => {
57
+ const onCancel = vi.fn();
58
+ expect(() => {
59
+ renderHook(() => useCrudShortcuts({ onCancel }));
60
+ }).not.toThrow();
61
+ });
62
+
63
+ it('registers onSearch shortcut', () => {
64
+ const onSearch = vi.fn();
65
+ expect(() => {
66
+ renderHook(() => useCrudShortcuts({ onSearch }));
67
+ }).not.toThrow();
68
+ });
69
+
70
+ it('registers all shortcuts together', () => {
71
+ expect(() => {
72
+ renderHook(() =>
73
+ useCrudShortcuts({
74
+ onCreate: vi.fn(),
75
+ onEdit: vi.fn(),
76
+ onDelete: vi.fn(),
77
+ onSave: vi.fn(),
78
+ onDuplicate: vi.fn(),
79
+ onCancel: vi.fn(),
80
+ onSearch: vi.fn(),
81
+ })
82
+ );
83
+ }).not.toThrow();
84
+ });
85
+ });
@@ -0,0 +1,83 @@
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, afterEach } from 'vitest';
10
+ import { renderHook, act } from '@testing-library/react';
11
+ import { useDebugMode } from '../useDebugMode';
12
+
13
+ describe('useDebugMode', () => {
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it('defaults to disabled when no URL params are present', () => {
19
+ const { result } = renderHook(() => useDebugMode());
20
+ expect(result.current.enabled).toBe(false);
21
+ expect(result.current.flags.enabled).toBe(false);
22
+ });
23
+
24
+ it('provides toggle function', () => {
25
+ const { result } = renderHook(() => useDebugMode());
26
+ expect(typeof result.current.toggle).toBe('function');
27
+ });
28
+
29
+ it('toggles enabled state on toggle()', () => {
30
+ const { result } = renderHook(() => useDebugMode());
31
+ expect(result.current.enabled).toBe(false);
32
+
33
+ act(() => {
34
+ result.current.toggle();
35
+ });
36
+ expect(result.current.enabled).toBe(true);
37
+
38
+ act(() => {
39
+ result.current.toggle();
40
+ });
41
+ expect(result.current.enabled).toBe(false);
42
+ });
43
+
44
+ it('setEnabled explicitly sets the state', () => {
45
+ const { result } = renderHook(() => useDebugMode());
46
+
47
+ act(() => {
48
+ result.current.setEnabled(true);
49
+ });
50
+ expect(result.current.enabled).toBe(true);
51
+
52
+ act(() => {
53
+ result.current.setEnabled(false);
54
+ });
55
+ expect(result.current.enabled).toBe(false);
56
+ });
57
+
58
+ it('responds to Ctrl+Shift+D keyboard shortcut', () => {
59
+ const { result } = renderHook(() => useDebugMode());
60
+ expect(result.current.enabled).toBe(false);
61
+
62
+ act(() => {
63
+ const event = new KeyboardEvent('keydown', {
64
+ key: 'D',
65
+ ctrlKey: true,
66
+ shiftKey: true,
67
+ bubbles: true,
68
+ });
69
+ document.dispatchEvent(event);
70
+ });
71
+
72
+ expect(result.current.enabled).toBe(true);
73
+ });
74
+
75
+ it('flags reflect enabled state', () => {
76
+ const { result } = renderHook(() => useDebugMode());
77
+
78
+ act(() => {
79
+ result.current.setEnabled(true);
80
+ });
81
+ expect(result.current.flags.enabled).toBe(true);
82
+ });
83
+ });
@@ -44,4 +44,251 @@ describe('useFocusTrap', () => {
44
44
  // Should not throw
45
45
  expect(result.current.current).toBeNull();
46
46
  });
47
+
48
+ it('auto-focuses the first focusable element when enabled', () => {
49
+ const container = document.createElement('div');
50
+ const button = document.createElement('button');
51
+ button.textContent = 'Click me';
52
+ container.appendChild(button);
53
+ document.body.appendChild(container);
54
+
55
+ const { result } = renderHook(() =>
56
+ useFocusTrap<HTMLDivElement>({ enabled: true, autoFocus: true })
57
+ );
58
+
59
+ act(() => {
60
+ (result.current as any).current = container;
61
+ });
62
+
63
+ // Re-render to trigger effect with the ref set
64
+ const { result: result2 } = renderHook(() =>
65
+ useFocusTrap<HTMLDivElement>({ enabled: true, autoFocus: true })
66
+ );
67
+
68
+ document.body.removeChild(container);
69
+ });
70
+
71
+ it('focuses initialFocus selector when provided', () => {
72
+ const container = document.createElement('div');
73
+ const input1 = document.createElement('input');
74
+ const input2 = document.createElement('input');
75
+ input2.className = 'target-input';
76
+ container.appendChild(input1);
77
+ container.appendChild(input2);
78
+ document.body.appendChild(container);
79
+
80
+ const focusSpy = vi.spyOn(input2, 'focus');
81
+
82
+ const { result, rerender } = renderHook(
83
+ ({ enabled }) =>
84
+ useFocusTrap<HTMLDivElement>({
85
+ enabled,
86
+ autoFocus: true,
87
+ initialFocus: '.target-input',
88
+ }),
89
+ { initialProps: { enabled: false } }
90
+ );
91
+
92
+ // Set the ref
93
+ (result.current as any).current = container;
94
+
95
+ // Enable the trap to trigger the effect
96
+ rerender({ enabled: true });
97
+
98
+ expect(focusSpy).toHaveBeenCalled();
99
+
100
+ document.body.removeChild(container);
101
+ });
102
+
103
+ it('handles Escape key when escapeDeactivates is true', () => {
104
+ const container = document.createElement('div');
105
+ const button = document.createElement('button');
106
+ container.appendChild(button);
107
+ document.body.appendChild(container);
108
+
109
+ const onEscape = vi.fn();
110
+
111
+ const { result, rerender } = renderHook(
112
+ ({ enabled }) =>
113
+ useFocusTrap<HTMLDivElement>({
114
+ enabled,
115
+ escapeDeactivates: true,
116
+ onEscape,
117
+ }),
118
+ { initialProps: { enabled: false } }
119
+ );
120
+
121
+ (result.current as any).current = container;
122
+ rerender({ enabled: true });
123
+
124
+ act(() => {
125
+ const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
126
+ container.dispatchEvent(event);
127
+ });
128
+
129
+ expect(onEscape).toHaveBeenCalled();
130
+
131
+ document.body.removeChild(container);
132
+ });
133
+
134
+ it('traps Tab focus at the end of focusable elements', () => {
135
+ const container = document.createElement('div');
136
+ const button1 = document.createElement('button');
137
+ button1.textContent = 'First';
138
+ const button2 = document.createElement('button');
139
+ button2.textContent = 'Last';
140
+ container.appendChild(button1);
141
+ container.appendChild(button2);
142
+ document.body.appendChild(container);
143
+
144
+ const { result, rerender } = renderHook(
145
+ ({ enabled }) =>
146
+ useFocusTrap<HTMLDivElement>({
147
+ enabled,
148
+ autoFocus: false,
149
+ }),
150
+ { initialProps: { enabled: false } }
151
+ );
152
+
153
+ (result.current as any).current = container;
154
+ rerender({ enabled: true });
155
+
156
+ // Focus the last element
157
+ button2.focus();
158
+
159
+ act(() => {
160
+ const event = new KeyboardEvent('keydown', {
161
+ key: 'Tab',
162
+ bubbles: true,
163
+ });
164
+ container.dispatchEvent(event);
165
+ });
166
+
167
+ document.body.removeChild(container);
168
+ });
169
+
170
+ it('traps Shift+Tab focus at the start of focusable elements', () => {
171
+ const container = document.createElement('div');
172
+ const button1 = document.createElement('button');
173
+ button1.textContent = 'First';
174
+ const button2 = document.createElement('button');
175
+ button2.textContent = 'Last';
176
+ container.appendChild(button1);
177
+ container.appendChild(button2);
178
+ document.body.appendChild(container);
179
+
180
+ const { result, rerender } = renderHook(
181
+ ({ enabled }) =>
182
+ useFocusTrap<HTMLDivElement>({
183
+ enabled,
184
+ autoFocus: false,
185
+ }),
186
+ { initialProps: { enabled: false } }
187
+ );
188
+
189
+ (result.current as any).current = container;
190
+ rerender({ enabled: true });
191
+
192
+ // Focus the first element
193
+ button1.focus();
194
+
195
+ act(() => {
196
+ const event = new KeyboardEvent('keydown', {
197
+ key: 'Tab',
198
+ shiftKey: true,
199
+ bubbles: true,
200
+ });
201
+ container.dispatchEvent(event);
202
+ });
203
+
204
+ document.body.removeChild(container);
205
+ });
206
+
207
+ it('restores focus on unmount when restoreFocus is true', () => {
208
+ const outsideButton = document.createElement('button');
209
+ outsideButton.textContent = 'Outside';
210
+ document.body.appendChild(outsideButton);
211
+ outsideButton.focus();
212
+
213
+ const container = document.createElement('div');
214
+ const innerButton = document.createElement('button');
215
+ innerButton.textContent = 'Inside';
216
+ container.appendChild(innerButton);
217
+ document.body.appendChild(container);
218
+
219
+ const { result, rerender, unmount } = renderHook(
220
+ ({ enabled }) =>
221
+ useFocusTrap<HTMLDivElement>({
222
+ enabled,
223
+ autoFocus: true,
224
+ restoreFocus: true,
225
+ }),
226
+ { initialProps: { enabled: false } }
227
+ );
228
+
229
+ (result.current as any).current = container;
230
+ rerender({ enabled: true });
231
+
232
+ unmount();
233
+
234
+ document.body.removeChild(container);
235
+ document.body.removeChild(outsideButton);
236
+ });
237
+
238
+ it('ignores non-Tab and non-Escape keys', () => {
239
+ const container = document.createElement('div');
240
+ const button = document.createElement('button');
241
+ container.appendChild(button);
242
+ document.body.appendChild(container);
243
+
244
+ const { result, rerender } = renderHook(
245
+ ({ enabled }) =>
246
+ useFocusTrap<HTMLDivElement>({
247
+ enabled,
248
+ autoFocus: false,
249
+ }),
250
+ { initialProps: { enabled: false } }
251
+ );
252
+
253
+ (result.current as any).current = container;
254
+ rerender({ enabled: true });
255
+
256
+ act(() => {
257
+ const event = new KeyboardEvent('keydown', {
258
+ key: 'a',
259
+ bubbles: true,
260
+ });
261
+ container.dispatchEvent(event);
262
+ });
263
+
264
+ document.body.removeChild(container);
265
+ });
266
+
267
+ it('handles container with no focusable elements', () => {
268
+ const container = document.createElement('div');
269
+ container.innerHTML = '<span>No focusable elements</span>';
270
+ document.body.appendChild(container);
271
+
272
+ const { result, rerender } = renderHook(
273
+ ({ enabled }) =>
274
+ useFocusTrap<HTMLDivElement>({
275
+ enabled,
276
+ autoFocus: true,
277
+ }),
278
+ { initialProps: { enabled: false } }
279
+ );
280
+
281
+ (result.current as any).current = container;
282
+ rerender({ enabled: true });
283
+
284
+ act(() => {
285
+ const event = new KeyboardEvent('keydown', {
286
+ key: 'Tab',
287
+ bubbles: true,
288
+ });
289
+ container.dispatchEvent(event);
290
+ });
291
+
292
+ document.body.removeChild(container);
293
+ });
47
294
  });