@envive-ai/react-hooks 0.3.40 → 0.3.41

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 (53) hide show
  1. package/dist/atoms/app/index.d.cts +1 -1
  2. package/dist/atoms/app/index.d.ts +6 -6
  3. package/dist/atoms/chat/chatState.d.cts +18 -18
  4. package/dist/atoms/chat/chatState.d.ts +19 -19
  5. package/dist/atoms/chat/form.d.ts +2 -2
  6. package/dist/atoms/chat/index.d.cts +3 -3
  7. package/dist/atoms/chat/index.d.ts +3 -3
  8. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  9. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  10. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  11. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  12. package/dist/atoms/chat/replies.d.cts +3 -3
  13. package/dist/atoms/chat/replies.d.ts +3 -3
  14. package/dist/atoms/chat/suggestions.d.ts +2 -2
  15. package/dist/atoms/envive/enviveConfig.d.cts +12 -12
  16. package/dist/atoms/envive/enviveConfig.d.ts +13 -13
  17. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  18. package/dist/atoms/org/customerService.d.cts +6 -6
  19. package/dist/atoms/org/customerService.d.ts +6 -6
  20. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  21. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  22. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  23. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  24. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  25. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  26. package/dist/atoms/search/chatSearch.d.cts +17 -17
  27. package/dist/atoms/search/chatSearch.d.ts +17 -17
  28. package/dist/atoms/search/searchAPI.d.cts +13 -13
  29. package/dist/atoms/search/searchAPI.d.ts +13 -13
  30. package/dist/atoms/search/types.d.cts +1 -1
  31. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  32. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  33. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.ts +2 -2
  34. package/dist/contexts/types.d.cts +1 -1
  35. package/dist/contexts/types.d.ts +1 -1
  36. package/dist/contexts/typesV3.d.cts +1 -1
  37. package/dist/contexts/typesV3.d.ts +1 -1
  38. package/dist/hooks/FocusTrap/index.cjs +3 -0
  39. package/dist/hooks/FocusTrap/index.d.cts +2 -0
  40. package/dist/hooks/FocusTrap/index.d.ts +2 -0
  41. package/dist/hooks/FocusTrap/index.js +3 -0
  42. package/dist/hooks/FocusTrap/useFocusTrap.cjs +105 -0
  43. package/dist/hooks/FocusTrap/useFocusTrap.d.cts +20 -0
  44. package/dist/hooks/FocusTrap/useFocusTrap.d.ts +20 -0
  45. package/dist/hooks/FocusTrap/useFocusTrap.js +104 -0
  46. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  47. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.ts +2 -2
  48. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  49. package/dist/hooks/utils.d.ts +1 -1
  50. package/package.json +5 -1
  51. package/src/hooks/FocusTrap/__tests__/useFocusTrap.test.tsx +236 -0
  52. package/src/hooks/FocusTrap/index.ts +1 -0
  53. package/src/hooks/FocusTrap/useFocusTrap.ts +125 -0
@@ -0,0 +1,236 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { useRef, useState } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+ import { useFocusTrap } from '../useFocusTrap';
6
+
7
+ const TestDialog = ({ enabled }: { enabled: boolean }) => {
8
+ const ref = useRef<HTMLDivElement>(null);
9
+ useFocusTrap(ref, enabled);
10
+ return enabled ? (
11
+ <div
12
+ ref={ref}
13
+ data-testid="dialog"
14
+ role="dialog"
15
+ >
16
+ <button
17
+ type="button"
18
+ data-testid="close"
19
+ >
20
+ Close
21
+ </button>
22
+ <input data-testid="input" />
23
+ <button
24
+ type="button"
25
+ data-testid="action"
26
+ >
27
+ Action
28
+ </button>
29
+ </div>
30
+ ) : null;
31
+ };
32
+
33
+ const LazyContentDialog = () => {
34
+ const ref = useRef<HTMLDivElement>(null);
35
+ const [loaded, setLoaded] = useState(false);
36
+ useFocusTrap(ref, true);
37
+ return (
38
+ <div>
39
+ <button
40
+ type="button"
41
+ data-testid="load"
42
+ onClick={() => setLoaded(true)}
43
+ >
44
+ Load
45
+ </button>
46
+ <div
47
+ ref={ref}
48
+ data-testid="dialog"
49
+ role="dialog"
50
+ >
51
+ {loaded && (
52
+ <>
53
+ <button
54
+ type="button"
55
+ data-testid="close"
56
+ >
57
+ Close
58
+ </button>
59
+ <button
60
+ type="button"
61
+ data-testid="action"
62
+ >
63
+ Action
64
+ </button>
65
+ </>
66
+ )}
67
+ </div>
68
+ </div>
69
+ );
70
+ };
71
+
72
+ describe('useFocusTrap', () => {
73
+ it('moves focus to the first focusable element inside the container when enabled', () => {
74
+ render(<TestDialog enabled />);
75
+ expect(screen.getByTestId('close')).toHaveFocus();
76
+ });
77
+
78
+ it('does nothing while disabled', () => {
79
+ const { container } = render(
80
+ <>
81
+ <button
82
+ type="button"
83
+ data-testid="outside"
84
+ >
85
+ Outside
86
+ </button>
87
+ <TestDialog enabled={false} />
88
+ </>,
89
+ );
90
+ const outside = screen.getByTestId('outside');
91
+ outside.focus();
92
+ expect(outside).toHaveFocus();
93
+ expect(container.querySelector('[data-testid="dialog"]')).toBeNull();
94
+ });
95
+
96
+ it('wraps Tab from the last focusable back to the first', async () => {
97
+ const user = userEvent.setup();
98
+ render(<TestDialog enabled />);
99
+ const close = screen.getByTestId('close');
100
+ const action = screen.getByTestId('action');
101
+
102
+ expect(close).toHaveFocus();
103
+ action.focus();
104
+ expect(action).toHaveFocus();
105
+
106
+ await user.tab();
107
+ expect(close).toHaveFocus();
108
+ });
109
+
110
+ it('wraps Shift+Tab from the first focusable back to the last', async () => {
111
+ const user = userEvent.setup();
112
+ render(<TestDialog enabled />);
113
+ const close = screen.getByTestId('close');
114
+ const action = screen.getByTestId('action');
115
+
116
+ expect(close).toHaveFocus();
117
+
118
+ await user.tab({ shift: true });
119
+ expect(action).toHaveFocus();
120
+ });
121
+
122
+ it('restores focus to the previously focused element on disable', () => {
123
+ const Wrapper = () => {
124
+ const [open, setOpen] = useState(false);
125
+ return (
126
+ <div>
127
+ <button
128
+ type="button"
129
+ data-testid="trigger"
130
+ onClick={() => setOpen(true)}
131
+ >
132
+ Open
133
+ </button>
134
+ {open && (
135
+ <button
136
+ type="button"
137
+ data-testid="programmatic-close"
138
+ onClick={() => setOpen(false)}
139
+ >
140
+ Programmatic close
141
+ </button>
142
+ )}
143
+ <TestDialog enabled={open} />
144
+ </div>
145
+ );
146
+ };
147
+ render(<Wrapper />);
148
+ const trigger = screen.getByTestId('trigger');
149
+ trigger.focus();
150
+ expect(trigger).toHaveFocus();
151
+
152
+ act(() => {
153
+ trigger.click();
154
+ });
155
+ expect(screen.getByTestId('close')).toHaveFocus();
156
+
157
+ act(() => {
158
+ screen.getByTestId('programmatic-close').click();
159
+ });
160
+ expect(trigger).toHaveFocus();
161
+ });
162
+
163
+ it('waits for lazy content and focuses the first focusable when it appears', async () => {
164
+ render(<LazyContentDialog />);
165
+ const load = screen.getByTestId('load');
166
+ expect(load).not.toHaveFocus();
167
+
168
+ await act(async () => {
169
+ load.click();
170
+ });
171
+
172
+ expect(screen.getByTestId('close')).toHaveFocus();
173
+ });
174
+
175
+ it('keeps focus moving freely inside the trap when the container lives in a shadow root', () => {
176
+ // Build the host + shadow root manually so we can mount our React tree inside it.
177
+ const host = document.createElement('div');
178
+ document.body.appendChild(host);
179
+ const shadow = host.attachShadow({ mode: 'open' });
180
+ const mountPoint = document.createElement('div');
181
+ shadow.appendChild(mountPoint);
182
+
183
+ const ShadowDialog = () => {
184
+ const ref = useRef<HTMLDivElement>(null);
185
+ useFocusTrap(ref, true);
186
+ return (
187
+ <div
188
+ ref={ref}
189
+ data-testid="shadow-dialog"
190
+ >
191
+ <button
192
+ type="button"
193
+ data-testid="shadow-close"
194
+ >
195
+ Close
196
+ </button>
197
+ <button
198
+ type="button"
199
+ data-testid="shadow-action"
200
+ >
201
+ Action
202
+ </button>
203
+ </div>
204
+ );
205
+ };
206
+
207
+ // Render React into the shadow root mount point via createRoot to make sure
208
+ // we are exercising the shadow-DOM-aware code paths.
209
+ const reactRoot = createRoot(mountPoint);
210
+ act(() => {
211
+ reactRoot.render(<ShadowDialog />);
212
+ });
213
+
214
+ const close = shadow.querySelector('[data-testid="shadow-close"]') as HTMLButtonElement;
215
+ const action = shadow.querySelector('[data-testid="shadow-action"]') as HTMLButtonElement;
216
+ expect(shadow.activeElement).toBe(close);
217
+
218
+ // Manually move focus to the action button (mimicking a Tab away from first)
219
+ // and dispatch a non-shift Tab. The handler should NOT preventDefault because
220
+ // the active element is inside the container (verified via shadow root).
221
+ action.focus();
222
+ expect(shadow.activeElement).toBe(action);
223
+
224
+ const evt = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
225
+ act(() => {
226
+ action.dispatchEvent(evt);
227
+ });
228
+ // Since action is `last`, the trap wraps focus back to `close`.
229
+ expect(shadow.activeElement).toBe(close);
230
+
231
+ act(() => {
232
+ reactRoot.unmount();
233
+ });
234
+ host.remove();
235
+ });
236
+ });
@@ -0,0 +1 @@
1
+ export * from './useFocusTrap';
@@ -0,0 +1,125 @@
1
+ import { RefObject, useEffect } from 'react';
2
+
3
+ const FOCUSABLE_SELECTOR = [
4
+ 'a[href]',
5
+ 'button:not([disabled])',
6
+ 'input:not([disabled]):not([type="hidden"])',
7
+ 'textarea:not([disabled])',
8
+ 'select:not([disabled])',
9
+ '[tabindex]:not([tabindex="-1"])',
10
+ ].join(',');
11
+
12
+ const isElementVisible = (el: HTMLElement): boolean => {
13
+ if (el.hasAttribute('hidden')) return false;
14
+ if (typeof window === 'undefined') return true;
15
+ const style = window.getComputedStyle(el);
16
+ return style.display !== 'none' && style.visibility !== 'hidden';
17
+ };
18
+
19
+ const getFocusableElements = (container: HTMLElement): HTMLElement[] =>
20
+ Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(isElementVisible);
21
+
22
+ /**
23
+ * Returns the deepest active element across (possibly nested) shadow roots,
24
+ * starting from the document. Plain `document.activeElement` returns the shadow
25
+ * host when focus is inside an open shadow root, so we recurse into shadowRoot
26
+ * to find the truly focused element.
27
+ */
28
+ const getDeepActiveElement = (): HTMLElement | null => {
29
+ if (typeof document === 'undefined') return null;
30
+ let active = document.activeElement as HTMLElement | null;
31
+ while (active && active.shadowRoot && active.shadowRoot.activeElement) {
32
+ active = active.shadowRoot.activeElement as HTMLElement;
33
+ }
34
+ return active;
35
+ };
36
+
37
+ /**
38
+ * Trap keyboard focus inside `containerRef` while `enabled` is true.
39
+ *
40
+ * On enable: moves focus to the first focusable element inside the container.
41
+ * If the container is empty (e.g. lazy-loaded content), waits for DOM mutations
42
+ * and focuses the first focusable as soon as it appears.
43
+ *
44
+ * While enabled: Tab and Shift+Tab wrap around so focus stays inside.
45
+ *
46
+ * On disable: restores focus to the previously focused element if it is still
47
+ * present in the document.
48
+ */
49
+ export const useFocusTrap = (containerRef: RefObject<HTMLElement | null>, enabled: boolean) => {
50
+ useEffect(() => {
51
+ if (!enabled || typeof document === 'undefined') return undefined;
52
+
53
+ const container = containerRef.current;
54
+ if (!container) return undefined;
55
+
56
+ // The container may live inside a Shadow DOM (the injection bundle hosts
57
+ // the chat that way). Use the container's root so containment checks and
58
+ // active-element lookups work both in the light DOM and inside shadow roots.
59
+ const root = container.getRootNode() as Document | ShadowRoot;
60
+ const previouslyFocused = getDeepActiveElement();
61
+
62
+ const getActiveElement = (): HTMLElement | null => root.activeElement as HTMLElement | null;
63
+
64
+ const focusFirst = (): boolean => {
65
+ const focusables = getFocusableElements(container);
66
+ const first = focusables[0];
67
+ if (first) {
68
+ first.focus();
69
+ return true;
70
+ }
71
+ return false;
72
+ };
73
+
74
+ let observer: MutationObserver | null = null;
75
+ if (!focusFirst()) {
76
+ observer = new MutationObserver(() => {
77
+ if (focusFirst()) {
78
+ observer?.disconnect();
79
+ observer = null;
80
+ }
81
+ });
82
+ observer.observe(container, { childList: true, subtree: true });
83
+ }
84
+
85
+ const handleKeyDown = (e: KeyboardEvent) => {
86
+ if (e.key !== 'Tab') return;
87
+
88
+ const focusables = getFocusableElements(container);
89
+ if (focusables.length === 0) {
90
+ e.preventDefault();
91
+ return;
92
+ }
93
+
94
+ const first = focusables[0];
95
+ const last = focusables[focusables.length - 1];
96
+ const active = getActiveElement();
97
+ const isInsideContainer = active ? container.contains(active) : false;
98
+
99
+ if (e.shiftKey) {
100
+ if (active === first || !isInsideContainer) {
101
+ e.preventDefault();
102
+ last.focus();
103
+ }
104
+ } else if (active === last || !isInsideContainer) {
105
+ e.preventDefault();
106
+ first.focus();
107
+ }
108
+ };
109
+
110
+ container.addEventListener('keydown', handleKeyDown);
111
+
112
+ return () => {
113
+ observer?.disconnect();
114
+ container.removeEventListener('keydown', handleKeyDown);
115
+
116
+ if (
117
+ previouslyFocused &&
118
+ typeof previouslyFocused.focus === 'function' &&
119
+ previouslyFocused.isConnected
120
+ ) {
121
+ previouslyFocused.focus();
122
+ }
123
+ };
124
+ }, [enabled, containerRef]);
125
+ };