@tpzdsp/next-toolkit 1.1.0 → 1.2.1
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.
- package/package.json +70 -8
- package/src/assets/styles/globals.css +2 -0
- package/src/assets/styles/ol.css +122 -0
- package/src/components/Button/Button.test.tsx +1 -1
- package/src/components/Button/Button.tsx +1 -1
- package/src/components/Card/Card.test.tsx +1 -1
- package/src/components/ErrorText/ErrorText.test.tsx +1 -1
- package/src/components/ErrorText/ErrorText.tsx +1 -1
- package/src/components/Heading/Heading.test.tsx +1 -1
- package/src/components/Hint/Hint.test.tsx +1 -1
- package/src/components/Hint/Hint.tsx +1 -1
- package/src/components/Modal/Modal.stories.tsx +252 -0
- package/src/components/Modal/Modal.test.tsx +248 -0
- package/src/components/Modal/Modal.tsx +61 -0
- package/src/components/Paragraph/Paragraph.test.tsx +1 -1
- package/src/components/SlidingPanel/SlidingPanel.test.tsx +1 -2
- package/src/components/accordion/Accordion.stories.tsx +235 -0
- package/src/components/accordion/Accordion.test.tsx +199 -0
- package/src/components/accordion/Accordion.tsx +47 -0
- package/src/components/divider/RuleDivider.stories.tsx +255 -0
- package/src/components/divider/RuleDivider.test.tsx +164 -0
- package/src/components/divider/RuleDivider.tsx +18 -0
- package/src/components/dropdown/DropdownMenu.test.tsx +1 -1
- package/src/components/dropdown/useDropdownMenu.ts +1 -1
- package/src/components/index.ts +6 -2
- package/src/components/layout/header/Header.tsx +2 -1
- package/src/components/layout/header/HeaderAuthClient.tsx +17 -9
- package/src/components/layout/header/HeaderNavClient.tsx +3 -3
- package/src/components/link/ExternalLink.tsx +1 -1
- package/src/components/link/Link.tsx +1 -1
- package/src/components/select/Select.stories.tsx +336 -0
- package/src/components/select/Select.test.tsx +473 -0
- package/src/components/select/Select.tsx +132 -0
- package/src/components/select/SelectSkeleton.stories.tsx +195 -0
- package/src/components/select/SelectSkeleton.test.tsx +105 -0
- package/src/components/select/SelectSkeleton.tsx +16 -0
- package/src/components/select/common.ts +4 -0
- package/src/contexts/index.ts +0 -5
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useClickOutside.test.ts +290 -0
- package/src/hooks/useClickOutside.ts +26 -0
- package/src/index.ts +3 -0
- package/src/map/LayerSwitcherControl.ts +147 -0
- package/src/map/Map.tsx +230 -0
- package/src/map/MapContext.tsx +211 -0
- package/src/map/Popup.tsx +74 -0
- package/src/map/basemaps.ts +79 -0
- package/src/map/geocoder.ts +61 -0
- package/src/map/geometries.ts +60 -0
- package/src/map/images/basemaps/OS.png +0 -0
- package/src/map/images/basemaps/dark.png +0 -0
- package/src/map/images/basemaps/sat-map-tiler.png +0 -0
- package/src/map/images/basemaps/satellite-map-tiler.png +0 -0
- package/src/map/images/basemaps/satellite.png +0 -0
- package/src/map/images/basemaps/streets.png +0 -0
- package/src/map/images/openlayers-logo.png +0 -0
- package/src/map/index.ts +10 -0
- package/src/map/map.ts +40 -0
- package/src/map/osOpenNamesSearch.ts +54 -0
- package/src/map/projections.ts +14 -0
- package/src/ol-geocoder.d.ts +1 -0
- package/src/test/index.ts +1 -0
- package/src/types/api.ts +52 -0
- package/src/types/auth.ts +13 -0
- package/src/types/index.ts +6 -0
- package/src/types/map.ts +26 -0
- package/src/types/navigation.ts +8 -0
- package/src/types/utils.ts +13 -0
- package/src/utils/auth.ts +1 -1
- package/src/utils/http.ts +143 -0
- package/src/utils/index.ts +1 -1
- package/src/utils/utils.ts +1 -1
- package/src/components/link/NextLinkWrapper.tsx +0 -66
- package/src/contexts/ThemeContext.tsx +0 -72
- package/src/types.ts +0 -99
- /package/src/{utils → test}/renderers.tsx +0 -0
|
@@ -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 '../../test/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
|
+
};
|
package/src/contexts/index.ts
CHANGED
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useClickOutside } from './useClickOutside';
|
|
4
|
+
import { renderHook } from '../test/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
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -4,5 +4,8 @@ export * from './components';
|
|
|
4
4
|
// Export all utilities (these can be used in both client and server)
|
|
5
5
|
export * from './utils';
|
|
6
6
|
|
|
7
|
+
// Export all map related code (these can be used in both client and server)
|
|
8
|
+
export * from './map';
|
|
9
|
+
|
|
7
10
|
// Export all types
|
|
8
11
|
export * from './types';
|