@tpzdsp/next-toolkit 1.1.0 → 1.2.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 (49) hide show
  1. package/package.json +21 -2
  2. package/src/assets/styles/globals.css +2 -0
  3. package/src/assets/styles/ol.css +122 -0
  4. package/src/components/Modal/Modal.stories.tsx +252 -0
  5. package/src/components/Modal/Modal.test.tsx +248 -0
  6. package/src/components/Modal/Modal.tsx +61 -0
  7. package/src/components/accordion/Accordion.stories.tsx +235 -0
  8. package/src/components/accordion/Accordion.test.tsx +199 -0
  9. package/src/components/accordion/Accordion.tsx +47 -0
  10. package/src/components/divider/RuleDivider.stories.tsx +255 -0
  11. package/src/components/divider/RuleDivider.test.tsx +164 -0
  12. package/src/components/divider/RuleDivider.tsx +18 -0
  13. package/src/components/index.ts +9 -2
  14. package/src/components/layout/header/HeaderAuthClient.tsx +16 -8
  15. package/src/components/layout/header/HeaderNavClient.tsx +2 -2
  16. package/src/components/map/LayerSwitcherControl.ts +147 -0
  17. package/src/components/map/Map.tsx +230 -0
  18. package/src/components/map/MapContext.tsx +211 -0
  19. package/src/components/map/Popup.tsx +74 -0
  20. package/src/components/map/basemaps.ts +79 -0
  21. package/src/components/map/geocoder.ts +61 -0
  22. package/src/components/map/geometries.ts +60 -0
  23. package/src/components/map/images/basemaps/OS.png +0 -0
  24. package/src/components/map/images/basemaps/dark.png +0 -0
  25. package/src/components/map/images/basemaps/sat-map-tiler.png +0 -0
  26. package/src/components/map/images/basemaps/satellite-map-tiler.png +0 -0
  27. package/src/components/map/images/basemaps/satellite.png +0 -0
  28. package/src/components/map/images/basemaps/streets.png +0 -0
  29. package/src/components/map/images/openlayers-logo.png +0 -0
  30. package/src/components/map/index.ts +10 -0
  31. package/src/components/map/map.ts +40 -0
  32. package/src/components/map/osOpenNamesSearch.ts +54 -0
  33. package/src/components/map/projections.ts +14 -0
  34. package/src/components/select/Select.stories.tsx +336 -0
  35. package/src/components/select/Select.test.tsx +473 -0
  36. package/src/components/select/Select.tsx +132 -0
  37. package/src/components/select/SelectSkeleton.stories.tsx +195 -0
  38. package/src/components/select/SelectSkeleton.test.tsx +105 -0
  39. package/src/components/select/SelectSkeleton.tsx +16 -0
  40. package/src/components/select/common.ts +4 -0
  41. package/src/contexts/index.ts +0 -5
  42. package/src/hooks/index.ts +1 -0
  43. package/src/hooks/useClickOutside.test.ts +290 -0
  44. package/src/hooks/useClickOutside.ts +26 -0
  45. package/src/types.ts +51 -1
  46. package/src/utils/http.ts +143 -0
  47. package/src/utils/index.ts +1 -0
  48. package/src/components/link/NextLinkWrapper.tsx +0 -66
  49. package/src/contexts/ThemeContext.tsx +0 -72
@@ -0,0 +1,195 @@
1
+ /* eslint-disable storybook/no-renderer-packages */
2
+ import { useEffect, useState } from 'react';
3
+
4
+ import type { Meta, StoryObj } from '@storybook/react';
5
+
6
+ import { SelectSkeleton } from './SelectSkeleton';
7
+
8
+ const meta = {
9
+ title: 'Components/SelectSkeleton',
10
+ component: SelectSkeleton,
11
+ parameters: {
12
+ layout: 'centered',
13
+ },
14
+ tags: ['autodocs'],
15
+ } satisfies Meta<typeof SelectSkeleton>;
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ export const Default: Story = {};
21
+
22
+ export const InContainer: Story = {
23
+ render: () => (
24
+ <div className="w-80">
25
+ <SelectSkeleton />
26
+ </div>
27
+ ),
28
+ };
29
+
30
+ export const Multiple: Story = {
31
+ render: () => (
32
+ <div className="space-y-4 w-80">
33
+ <SelectSkeleton />
34
+
35
+ <SelectSkeleton />
36
+
37
+ <SelectSkeleton />
38
+ </div>
39
+ ),
40
+ };
41
+
42
+ export const InForm: Story = {
43
+ render: () => (
44
+ <form className="space-y-4 p-4 border rounded w-96">
45
+ <div>
46
+ <p className="block text-sm font-medium mb-1">Flavor</p>
47
+
48
+ <SelectSkeleton />
49
+ </div>
50
+
51
+ <div>
52
+ <p className="block text-sm font-medium mb-1">Toppings</p>
53
+
54
+ <SelectSkeleton />
55
+ </div>
56
+
57
+ <div>
58
+ <p className="block text-sm font-medium mb-1">Size</p>
59
+
60
+ <SelectSkeleton />
61
+ </div>
62
+ </form>
63
+ ),
64
+ };
65
+
66
+ export const WithOtherSkeletons: Story = {
67
+ render: () => (
68
+ <div className="space-y-4 p-4 border rounded w-96">
69
+ <div>
70
+ <div className="h-4 bg-gray-200 rounded w-1/4 mb-2 animate-pulse"></div>
71
+
72
+ <SelectSkeleton />
73
+ </div>
74
+
75
+ <div>
76
+ <div className="h-4 bg-gray-200 rounded w-1/3 mb-2 animate-pulse"></div>
77
+
78
+ <SelectSkeleton />
79
+ </div>
80
+
81
+ <div className="flex gap-4">
82
+ <div className="flex-1">
83
+ <div className="h-4 bg-gray-200 rounded w-1/2 mb-2 animate-pulse"></div>
84
+
85
+ <SelectSkeleton />
86
+ </div>
87
+
88
+ <div className="flex-1">
89
+ <div className="h-4 bg-gray-200 rounded w-2/3 mb-2 animate-pulse"></div>
90
+
91
+ <SelectSkeleton />
92
+ </div>
93
+ </div>
94
+ </div>
95
+ ),
96
+ };
97
+
98
+ export const DifferentWidths: Story = {
99
+ render: () => (
100
+ <div className="space-y-4">
101
+ <div className="w-32">
102
+ <div className="text-sm mb-1">Small (w-32)</div>
103
+
104
+ <SelectSkeleton />
105
+ </div>
106
+
107
+ <div className="w-64">
108
+ <div className="text-sm mb-1">Medium (w-64)</div>
109
+
110
+ <SelectSkeleton />
111
+ </div>
112
+
113
+ <div className="w-96">
114
+ <div className="text-sm mb-1">Large (w-96)</div>
115
+
116
+ <SelectSkeleton />
117
+ </div>
118
+
119
+ <div className="w-full max-w-lg">
120
+ <div className="text-sm mb-1">Full width (max-w-lg)</div>
121
+
122
+ <SelectSkeleton />
123
+ </div>
124
+ </div>
125
+ ),
126
+ };
127
+
128
+ export const LoadingTransition: Story = {
129
+ render: () => {
130
+ const [isLoading, setIsLoading] = useState(true);
131
+
132
+ useEffect(() => {
133
+ const timer = setTimeout(() => {
134
+ setIsLoading(false);
135
+ }, 3000);
136
+
137
+ return () => clearTimeout(timer);
138
+ }, []);
139
+
140
+ return (
141
+ <div className="space-y-4 w-80">
142
+ <div className="text-sm text-gray-600">
143
+ {isLoading ? 'Loading options...' : 'Options loaded!'}
144
+ </div>
145
+ {isLoading ? (
146
+ <SelectSkeleton />
147
+ ) : (
148
+ <select className="w-full p-2 border border-gray-300 rounded-md">
149
+ <option>Chocolate</option>
150
+
151
+ <option>Strawberry</option>
152
+
153
+ <option>Vanilla</option>
154
+ </select>
155
+ )}
156
+ <button
157
+ onClick={() => setIsLoading(true)}
158
+ className="px-3 py-1 bg-blue-500 text-white rounded text-sm"
159
+ >
160
+ Reload
161
+ </button>
162
+ </div>
163
+ );
164
+ },
165
+ };
166
+
167
+ export const GridLayout: Story = {
168
+ render: () => (
169
+ <div className="grid grid-cols-2 gap-4 w-96">
170
+ <div>
171
+ <div className="h-4 bg-gray-200 rounded w-3/4 mb-2 animate-pulse"></div>
172
+
173
+ <SelectSkeleton />
174
+ </div>
175
+
176
+ <div>
177
+ <div className="h-4 bg-gray-200 rounded w-2/3 mb-2 animate-pulse"></div>
178
+
179
+ <SelectSkeleton />
180
+ </div>
181
+
182
+ <div>
183
+ <div className="h-4 bg-gray-200 rounded w-1/2 mb-2 animate-pulse"></div>
184
+
185
+ <SelectSkeleton />
186
+ </div>
187
+
188
+ <div>
189
+ <div className="h-4 bg-gray-200 rounded w-5/6 mb-2 animate-pulse"></div>
190
+
191
+ <SelectSkeleton />
192
+ </div>
193
+ </div>
194
+ ),
195
+ };
@@ -0,0 +1,105 @@
1
+ import { SelectSkeleton } from './SelectSkeleton';
2
+ import { render, screen } from '../../utils/renderers';
3
+
4
+ const LOADING_OPTIONS_LABEL = 'Loading options';
5
+
6
+ describe('SelectSkeleton', () => {
7
+ it('should render with default props', () => {
8
+ render(<SelectSkeleton />);
9
+
10
+ const skeleton = screen.getByLabelText(LOADING_OPTIONS_LABEL);
11
+
12
+ expect(skeleton).toBeInTheDocument();
13
+ });
14
+
15
+ it('should have correct CSS classes for skeleton styling', () => {
16
+ const { container } = render(<SelectSkeleton />);
17
+
18
+ const skeletonContainer = container.firstChild as HTMLElement;
19
+
20
+ // Just verify it's a div element - we can't easily test the exact Tailwind classes from SELECT_CONTAINER_CLASSES
21
+ expect(skeletonContainer.tagName).toBe('DIV');
22
+
23
+ const skeletonElement = screen.getByLabelText(LOADING_OPTIONS_LABEL);
24
+
25
+ expect(skeletonElement).toHaveClass(
26
+ 'w-full',
27
+ 'h-full',
28
+ 'bg-gray-100',
29
+ 'animate-pulse',
30
+ 'rounded-md',
31
+ 'col-span-2',
32
+ );
33
+ });
34
+
35
+ it('should have proper accessibility attributes', () => {
36
+ render(<SelectSkeleton />);
37
+
38
+ const skeleton = screen.getByLabelText(LOADING_OPTIONS_LABEL);
39
+
40
+ expect(skeleton).toHaveAttribute('aria-label', LOADING_OPTIONS_LABEL);
41
+ });
42
+
43
+ it('should render skeleton with correct structure', () => {
44
+ const { container } = render(<SelectSkeleton />);
45
+
46
+ // Should have container div
47
+ const containerDiv = container.firstChild;
48
+
49
+ expect(containerDiv).toBeInTheDocument();
50
+
51
+ // Should have control div inside container
52
+ const controlDiv = containerDiv?.firstChild;
53
+
54
+ expect(controlDiv).toBeInTheDocument();
55
+
56
+ // Should have skeleton div inside control
57
+ const skeletonDiv = controlDiv?.firstChild;
58
+
59
+ expect(skeletonDiv).toBeInTheDocument();
60
+ expect(skeletonDiv).toHaveAttribute('aria-label', LOADING_OPTIONS_LABEL);
61
+ });
62
+
63
+ it('should apply SELECT_CONTAINER_CLASSES to container', () => {
64
+ const { container } = render(<SelectSkeleton />);
65
+
66
+ const containerElement = container.firstChild as HTMLElement;
67
+
68
+ // Since we can't easily test the exact classes from common.ts,
69
+ // we test that it's a div with some expected structure
70
+ expect(containerElement.tagName).toBe('DIV');
71
+ });
72
+
73
+ it('should apply SELECT_CONTROL_CLASSES and SELECT_MIN_HEIGHT to control element', () => {
74
+ const { container } = render(<SelectSkeleton />);
75
+
76
+ const controlElement = container.firstChild?.firstChild as HTMLElement;
77
+
78
+ expect(controlElement.tagName).toBe('DIV');
79
+ });
80
+
81
+ it('should be visually distinguishable as a loading state', () => {
82
+ render(<SelectSkeleton />);
83
+
84
+ const skeleton = screen.getByLabelText(LOADING_OPTIONS_LABEL);
85
+
86
+ // Should have animation class for loading indication
87
+ expect(skeleton).toHaveClass('animate-pulse');
88
+
89
+ // Should have background color for visual feedback
90
+ expect(skeleton).toHaveClass('bg-gray-100');
91
+ });
92
+
93
+ it('should maintain consistent dimensions with Select component', () => {
94
+ const { container } = render(<SelectSkeleton />);
95
+
96
+ const skeleton = screen.getByLabelText(LOADING_OPTIONS_LABEL);
97
+
98
+ // Should fill available space
99
+ expect(skeleton).toHaveClass('w-full', 'h-full');
100
+
101
+ // Container should have proper structure
102
+ expect(container.firstChild).toBeInTheDocument();
103
+ expect(container.firstChild?.firstChild).toBeInTheDocument();
104
+ });
105
+ });
@@ -0,0 +1,16 @@
1
+ import { twMerge } from 'tailwind-merge';
2
+
3
+ import { SELECT_CONTAINER_CLASSES, SELECT_CONTROL_CLASSES, SELECT_MIN_HEIGHT } from './common';
4
+
5
+ export const SelectSkeleton = () => {
6
+ return (
7
+ <div className={SELECT_CONTAINER_CLASSES}>
8
+ <div className={twMerge(SELECT_CONTROL_CLASSES, SELECT_MIN_HEIGHT)}>
9
+ <div
10
+ className="w-full h-full bg-gray-100 animate-pulse rounded-md col-span-2"
11
+ aria-label="Loading options"
12
+ ></div>
13
+ </div>
14
+ </div>
15
+ );
16
+ };
@@ -0,0 +1,4 @@
1
+ export const SELECT_MIN_HEIGHT = '!min-h-[38px]';
2
+ export const SELECT_CONTAINER_CLASSES = 'w-full h-max select-none !pointer-events-auto';
3
+ export const SELECT_CONTROL_CLASSES =
4
+ 'px-2 py-2 !grid gap-4 grid-cols-[1fr_min-content] w-full border h-full rounded-md';
@@ -1,5 +0,0 @@
1
- // Export all contexts and hooks
2
- export { useTheme, ThemeContext } from './ThemeContext';
3
-
4
- // Export context types
5
- export type { ThemeContextValue } from '../types';
@@ -1,6 +1,7 @@
1
1
  // Export all hooks
2
2
  export { useLocalStorage } from './useLocalStorage';
3
3
  export { useDebounce } from './useDebounce';
4
+ export { useClickOutside } from './useClickOutside';
4
5
 
5
6
  // Export hook types
6
7
  export type { UseLocalStorageReturn } from './useLocalStorage';
@@ -0,0 +1,290 @@
1
+ import { useRef } from 'react';
2
+
3
+ import { useClickOutside } from './useClickOutside';
4
+ import { renderHook } from '../utils/renderers';
5
+
6
+ describe('useClickOutside', () => {
7
+ it('should call clickAwayHandler when clicking outside the element', () => {
8
+ const clickAwayHandler = vi.fn();
9
+ const ref = { current: document.createElement('div') };
10
+
11
+ renderHook(() => useClickOutside(ref, clickAwayHandler));
12
+
13
+ // Create an element outside the ref
14
+ const outsideElement = document.createElement('div');
15
+
16
+ document.body.appendChild(outsideElement);
17
+
18
+ // Simulate click outside
19
+ const mouseEvent = new MouseEvent('mousedown', {
20
+ bubbles: true,
21
+ cancelable: true,
22
+ });
23
+
24
+ Object.defineProperty(mouseEvent, 'target', {
25
+ value: outsideElement,
26
+ enumerable: true,
27
+ });
28
+
29
+ window.dispatchEvent(mouseEvent);
30
+
31
+ expect(clickAwayHandler).toHaveBeenCalledTimes(1);
32
+
33
+ // Cleanup
34
+ document.body.removeChild(outsideElement);
35
+ });
36
+
37
+ it('should not call clickAwayHandler when clicking inside the element', () => {
38
+ const clickAwayHandler = vi.fn();
39
+ const ref = { current: document.createElement('div') };
40
+
41
+ renderHook(() => useClickOutside(ref, clickAwayHandler));
42
+
43
+ // Create an element inside the ref
44
+ const insideElement = document.createElement('span');
45
+
46
+ ref.current.appendChild(insideElement);
47
+
48
+ // Simulate click inside
49
+ const mouseEvent = new MouseEvent('mousedown', {
50
+ bubbles: true,
51
+ cancelable: true,
52
+ });
53
+
54
+ Object.defineProperty(mouseEvent, 'target', {
55
+ value: insideElement,
56
+ enumerable: true,
57
+ });
58
+
59
+ window.dispatchEvent(mouseEvent);
60
+
61
+ expect(clickAwayHandler).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('should not call clickAwayHandler when clicking on the element itself', () => {
65
+ const clickAwayHandler = vi.fn();
66
+ const ref = { current: document.createElement('div') };
67
+
68
+ renderHook(() => useClickOutside(ref, clickAwayHandler));
69
+
70
+ // Simulate click on the element itself
71
+ const mouseEvent = new MouseEvent('mousedown', {
72
+ bubbles: true,
73
+ cancelable: true,
74
+ });
75
+
76
+ Object.defineProperty(mouseEvent, 'target', {
77
+ value: ref.current,
78
+ enumerable: true,
79
+ });
80
+
81
+ window.dispatchEvent(mouseEvent);
82
+
83
+ expect(clickAwayHandler).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('should not call clickAwayHandler when ref.current is null', () => {
87
+ const clickAwayHandler = vi.fn();
88
+ const ref = { current: null };
89
+
90
+ renderHook(() => useClickOutside(ref, clickAwayHandler));
91
+
92
+ // Create an outside element
93
+ const outsideElement = document.createElement('div');
94
+
95
+ document.body.appendChild(outsideElement);
96
+
97
+ // Simulate click
98
+ const mouseEvent = new MouseEvent('mousedown', {
99
+ bubbles: true,
100
+ cancelable: true,
101
+ });
102
+
103
+ Object.defineProperty(mouseEvent, 'target', {
104
+ value: outsideElement,
105
+ enumerable: true,
106
+ });
107
+
108
+ window.dispatchEvent(mouseEvent);
109
+
110
+ expect(clickAwayHandler).not.toHaveBeenCalled();
111
+
112
+ // Cleanup
113
+ document.body.removeChild(outsideElement);
114
+ });
115
+
116
+ it('should not call clickAwayHandler when event target is not an HTMLElement', () => {
117
+ const clickAwayHandler = vi.fn();
118
+ const ref = { current: document.createElement('div') };
119
+
120
+ renderHook(() => useClickOutside(ref, clickAwayHandler));
121
+
122
+ // Simulate click with non-HTMLElement target
123
+ const mouseEvent = new MouseEvent('mousedown', {
124
+ bubbles: true,
125
+ cancelable: true,
126
+ });
127
+
128
+ Object.defineProperty(mouseEvent, 'target', {
129
+ value: document.createTextNode('text'),
130
+ enumerable: true,
131
+ });
132
+
133
+ window.dispatchEvent(mouseEvent);
134
+
135
+ expect(clickAwayHandler).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it('should clean up event listener when unmounted', () => {
139
+ const clickAwayHandler = vi.fn();
140
+ const ref = { current: document.createElement('div') };
141
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
142
+
143
+ const { unmount } = renderHook(() => useClickOutside(ref, clickAwayHandler));
144
+
145
+ unmount();
146
+
147
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
148
+
149
+ removeEventListenerSpy.mockRestore();
150
+ });
151
+
152
+ it('should update event listener when clickAwayHandler changes', () => {
153
+ const firstHandler = vi.fn();
154
+ const secondHandler = vi.fn();
155
+ const ref = { current: document.createElement('div') };
156
+
157
+ // Test with first handler
158
+ const { unmount: unmount1 } = renderHook(() => useClickOutside(ref, firstHandler));
159
+
160
+ // Create an outside element
161
+ const outsideElement = document.createElement('div');
162
+
163
+ document.body.appendChild(outsideElement);
164
+
165
+ // Simulate click outside with first handler
166
+ const mouseEvent = new MouseEvent('mousedown', {
167
+ bubbles: true,
168
+ cancelable: true,
169
+ });
170
+
171
+ Object.defineProperty(mouseEvent, 'target', {
172
+ value: outsideElement,
173
+ enumerable: true,
174
+ });
175
+
176
+ window.dispatchEvent(mouseEvent);
177
+
178
+ expect(firstHandler).toHaveBeenCalledTimes(1);
179
+ expect(secondHandler).not.toHaveBeenCalled();
180
+
181
+ // Cleanup first hook
182
+ unmount1();
183
+
184
+ // Test with second handler
185
+ renderHook(() => useClickOutside(ref, secondHandler));
186
+
187
+ // Simulate another click outside with second handler
188
+ window.dispatchEvent(mouseEvent);
189
+
190
+ expect(secondHandler).toHaveBeenCalledTimes(1);
191
+
192
+ // Cleanup
193
+ document.body.removeChild(outsideElement);
194
+ });
195
+
196
+ it('should update event listener when ref changes', () => {
197
+ const clickAwayHandler = vi.fn();
198
+ const firstRef = { current: document.createElement('div') };
199
+ const secondRef = { current: document.createElement('div') };
200
+
201
+ // Test with first ref
202
+ const { unmount: unmount1 } = renderHook(() => useClickOutside(firstRef, clickAwayHandler));
203
+
204
+ // Create an element inside the first ref
205
+ const insideFirstElement = document.createElement('span');
206
+
207
+ firstRef.current.appendChild(insideFirstElement);
208
+
209
+ // Cleanup first hook
210
+ unmount1();
211
+
212
+ // Test with second ref
213
+ renderHook(() => useClickOutside(secondRef, clickAwayHandler));
214
+
215
+ // Click on element that was inside the first ref (now should be outside the second ref)
216
+ const mouseEvent = new MouseEvent('mousedown', {
217
+ bubbles: true,
218
+ cancelable: true,
219
+ });
220
+
221
+ Object.defineProperty(mouseEvent, 'target', {
222
+ value: insideFirstElement,
223
+ enumerable: true,
224
+ });
225
+
226
+ window.dispatchEvent(mouseEvent);
227
+
228
+ expect(clickAwayHandler).toHaveBeenCalledTimes(1);
229
+ });
230
+
231
+ it('should work with deeply nested elements', () => {
232
+ const clickAwayHandler = vi.fn();
233
+ const ref = { current: document.createElement('div') };
234
+
235
+ renderHook(() => useClickOutside(ref, clickAwayHandler));
236
+
237
+ // Create deeply nested structure
238
+ const level1 = document.createElement('div');
239
+ const level2 = document.createElement('div');
240
+ const level3 = document.createElement('span');
241
+
242
+ ref.current.appendChild(level1);
243
+ level1.appendChild(level2);
244
+ level2.appendChild(level3);
245
+
246
+ // Click on deeply nested element (should not trigger handler)
247
+ const mouseEvent = new MouseEvent('mousedown', {
248
+ bubbles: true,
249
+ cancelable: true,
250
+ });
251
+
252
+ Object.defineProperty(mouseEvent, 'target', {
253
+ value: level3,
254
+ enumerable: true,
255
+ });
256
+
257
+ window.dispatchEvent(mouseEvent);
258
+
259
+ expect(clickAwayHandler).not.toHaveBeenCalled();
260
+ });
261
+
262
+ it('should work with real useRef hook', () => {
263
+ const clickAwayHandler = vi.fn();
264
+
265
+ const { result } = renderHook(() => {
266
+ const ref = useRef<HTMLDivElement>(null);
267
+
268
+ useClickOutside(ref, clickAwayHandler);
269
+
270
+ return ref;
271
+ });
272
+
273
+ // This test verifies the hook works with actual useRef
274
+ // The hook should be properly typed and not cause TypeScript errors
275
+ expect((result.current as React.RefObject<HTMLDivElement>).current).toBe(null); // Initially null
276
+ expect(clickAwayHandler).toBeDefined();
277
+ });
278
+
279
+ it('should add event listener on mount', () => {
280
+ const clickAwayHandler = vi.fn();
281
+ const ref = { current: document.createElement('div') };
282
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
283
+
284
+ renderHook(() => useClickOutside(ref, clickAwayHandler));
285
+
286
+ expect(addEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
287
+
288
+ addEventListenerSpy.mockRestore();
289
+ });
290
+ });
@@ -0,0 +1,26 @@
1
+ import { useEffect, type RefObject } from 'react';
2
+
3
+ export const useClickOutside = (
4
+ refElement: RefObject<HTMLElement | null>,
5
+ clickAwayHandler: () => void,
6
+ ) => {
7
+ useEffect(() => {
8
+ const listener = (event: MouseEvent) => {
9
+ const element = event.target;
10
+
11
+ if (
12
+ refElement.current &&
13
+ element instanceof HTMLElement &&
14
+ !refElement.current.contains(element)
15
+ ) {
16
+ clickAwayHandler();
17
+ }
18
+ };
19
+
20
+ window.addEventListener('mousedown', listener);
21
+
22
+ return () => {
23
+ window.removeEventListener('mousedown', listener);
24
+ };
25
+ }, [refElement, clickAwayHandler]);
26
+ };