@tpzdsp/next-toolkit 1.12.1 → 1.13.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 (35) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -6
  3. package/src/assets/styles/ol.css +147 -176
  4. package/src/components/InfoBox/InfoBox.stories.tsx +457 -0
  5. package/src/components/InfoBox/InfoBox.test.tsx +382 -0
  6. package/src/components/InfoBox/InfoBox.tsx +177 -0
  7. package/src/components/InfoBox/hooks/index.ts +3 -0
  8. package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +187 -0
  9. package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +69 -0
  10. package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +168 -0
  11. package/src/components/InfoBox/hooks/useInfoBoxState.ts +71 -0
  12. package/src/components/InfoBox/hooks/usePortalMount.test.ts +62 -0
  13. package/src/components/InfoBox/hooks/usePortalMount.ts +15 -0
  14. package/src/components/InfoBox/types.ts +6 -0
  15. package/src/components/InfoBox/utils/focusTrapConfig.test.ts +310 -0
  16. package/src/components/InfoBox/utils/focusTrapConfig.ts +59 -0
  17. package/src/components/InfoBox/utils/index.ts +2 -0
  18. package/src/components/InfoBox/utils/positionUtils.test.ts +170 -0
  19. package/src/components/InfoBox/utils/positionUtils.ts +89 -0
  20. package/src/components/index.ts +8 -0
  21. package/src/map/FullScreenControl.ts +126 -0
  22. package/src/map/LayerSwitcherControl.ts +87 -181
  23. package/src/map/LayerSwitcherPanel.tsx +173 -0
  24. package/src/map/MapComponent.tsx +6 -35
  25. package/src/map/createControlButton.ts +72 -0
  26. package/src/map/geocoder/Geocoder.test.tsx +115 -0
  27. package/src/map/geocoder/Geocoder.tsx +393 -0
  28. package/src/map/geocoder/groupResults.ts +12 -0
  29. package/src/map/geocoder/index.ts +4 -0
  30. package/src/map/geocoder/types.ts +11 -0
  31. package/src/map/index.ts +4 -1
  32. package/src/map/osOpenNamesSearch.ts +112 -57
  33. package/src/test/renderers.tsx +9 -20
  34. package/src/map/geocoder.ts +0 -61
  35. package/src/ol-geocoder.d.ts +0 -1
@@ -0,0 +1,187 @@
1
+ import type { RefObject } from 'react';
2
+
3
+ import { renderHook, act } from '../../../test/renderers';
4
+ import { POSITION_TOP_LEFT, POSITION_BOTTOM_RIGHT } from '../types';
5
+ import { useInfoBoxPosition } from './useInfoBoxPosition';
6
+
7
+ // Helper to avoid deep nesting in tests
8
+ const renderInfoBoxPosition = (props: Parameters<typeof useInfoBoxPosition>[0]) =>
9
+ renderHook(() => useInfoBoxPosition(props));
10
+
11
+ // Helper for rerenderable hook with isOpen parameter
12
+ const renderInfoBoxPositionWithIsOpen = (
13
+ triggerRef: RefObject<HTMLElement>,
14
+ forcedPosition?: Parameters<typeof useInfoBoxPosition>[0]['forcedPosition'],
15
+ initialIsOpen = false,
16
+ ) =>
17
+ renderHook(({ isOpen }) => useInfoBoxPosition({ isOpen, triggerRef, forcedPosition }), {
18
+ initialProps: { isOpen: initialIsOpen },
19
+ });
20
+
21
+ // Mock factory helpers to avoid deep nesting
22
+ const createMockRect = (top: number, left: number, width = 24, height = 24) => ({
23
+ top,
24
+ left,
25
+ bottom: top + height,
26
+ right: left + width,
27
+ width,
28
+ height,
29
+ x: left,
30
+ y: top,
31
+ toJSON: () => ({}),
32
+ });
33
+
34
+ describe('useInfoBoxPosition', () => {
35
+ let mockTriggerElement: HTMLElement;
36
+ let triggerRef: RefObject<HTMLElement>;
37
+
38
+ beforeEach(() => {
39
+ // Mock window dimensions
40
+ Object.defineProperty(globalThis, 'innerWidth', {
41
+ value: 1000,
42
+ writable: true,
43
+ configurable: true,
44
+ });
45
+ Object.defineProperty(globalThis, 'innerHeight', {
46
+ value: 800,
47
+ writable: true,
48
+ configurable: true,
49
+ });
50
+
51
+ // Create mock trigger element
52
+ mockTriggerElement = document.createElement('button');
53
+ triggerRef = { current: mockTriggerElement };
54
+
55
+ // Mock getBoundingClientRect
56
+ mockTriggerElement.getBoundingClientRect = vi.fn(() => createMockRect(100, 100));
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.clearAllMocks();
61
+ });
62
+
63
+ describe('initialization', () => {
64
+ it('should start with default position', () => {
65
+ const { result } = renderInfoBoxPosition({
66
+ isOpen: false,
67
+ triggerRef,
68
+ });
69
+
70
+ expect(result.current.calculatedPosition).toBe(POSITION_BOTTOM_RIGHT);
71
+ expect(result.current.contentPosition).toEqual({ top: 0, left: 0 });
72
+ });
73
+
74
+ it('should not update position when closed', () => {
75
+ renderInfoBoxPosition({
76
+ isOpen: false,
77
+ triggerRef,
78
+ });
79
+
80
+ expect(mockTriggerElement.getBoundingClientRect).not.toHaveBeenCalled();
81
+ });
82
+ });
83
+
84
+ describe('position calculation', () => {
85
+ it('should calculate position when opened', () => {
86
+ const { result, rerender } = renderInfoBoxPositionWithIsOpen(triggerRef);
87
+
88
+ rerender({ isOpen: true });
89
+
90
+ expect(result.current.calculatedPosition).toBe(POSITION_BOTTOM_RIGHT);
91
+ expect(result.current.contentPosition.top).toBeGreaterThan(0);
92
+ expect(result.current.contentPosition.left).toBeGreaterThan(0);
93
+ });
94
+
95
+ it('should use forced position when provided', () => {
96
+ const { result, rerender } = renderInfoBoxPositionWithIsOpen(triggerRef, POSITION_TOP_LEFT);
97
+
98
+ rerender({ isOpen: true });
99
+
100
+ expect(result.current.calculatedPosition).toBe(POSITION_TOP_LEFT);
101
+ });
102
+
103
+ it('should handle trigger in top-left quadrant', () => {
104
+ mockTriggerElement.getBoundingClientRect = vi.fn(() => createMockRect(100, 100));
105
+
106
+ const { result, rerender } = renderInfoBoxPositionWithIsOpen(triggerRef);
107
+
108
+ rerender({ isOpen: true });
109
+
110
+ // top=100 < 400 (half of 800) -> bottom
111
+ // left=100 < 500 (half of 1000) -> right
112
+ expect(result.current.calculatedPosition).toBe(POSITION_BOTTOM_RIGHT);
113
+ });
114
+
115
+ it('should handle trigger in bottom-right quadrant', () => {
116
+ mockTriggerElement.getBoundingClientRect = vi.fn(() => createMockRect(700, 800));
117
+
118
+ const { result, rerender } = renderInfoBoxPositionWithIsOpen(triggerRef);
119
+
120
+ rerender({ isOpen: true });
121
+
122
+ // top=700 >= 400 (half of 800) -> top
123
+ // left=800 >= 500 (half of 1000) -> left
124
+ expect(result.current.calculatedPosition).toBe(POSITION_TOP_LEFT);
125
+ });
126
+ });
127
+
128
+ describe('manual position update', () => {
129
+ it('should allow manual position updates', () => {
130
+ const { result, rerender } = renderInfoBoxPositionWithIsOpen(triggerRef);
131
+
132
+ rerender({ isOpen: true });
133
+
134
+ const initialPosition = { ...result.current.contentPosition };
135
+
136
+ // Change trigger position
137
+ mockTriggerElement.getBoundingClientRect = vi.fn(() => createMockRect(200, 200));
138
+
139
+ act(() => {
140
+ result.current.updateContentPosition();
141
+ });
142
+
143
+ expect(result.current.contentPosition).not.toEqual(initialPosition);
144
+ });
145
+ });
146
+
147
+ describe('event listeners', () => {
148
+ it('should add resize listener when open', () => {
149
+ const addEventListenerSpy = vi.spyOn(globalThis, 'addEventListener');
150
+
151
+ const { rerender } = renderInfoBoxPositionWithIsOpen(triggerRef);
152
+
153
+ rerender({ isOpen: true });
154
+
155
+ expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
156
+ expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
157
+ });
158
+
159
+ it('should remove listeners when closed', () => {
160
+ const removeEventListenerSpy = vi.spyOn(globalThis, 'removeEventListener');
161
+
162
+ const { rerender, unmount } = renderInfoBoxPositionWithIsOpen(triggerRef, undefined, true);
163
+
164
+ rerender({ isOpen: false });
165
+ unmount();
166
+
167
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
168
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
169
+ });
170
+ });
171
+
172
+ describe('edge cases', () => {
173
+ it('should handle null triggerRef', () => {
174
+ const nullRef = { current: null } as unknown as RefObject<HTMLElement>;
175
+
176
+ const { result } = renderInfoBoxPosition({
177
+ isOpen: true,
178
+ triggerRef: nullRef,
179
+ });
180
+
181
+ result.current.updateContentPosition();
182
+
183
+ // Should not throw - test passes if no error
184
+ expect(result.current.calculatedPosition).toBe(POSITION_BOTTOM_RIGHT);
185
+ });
186
+ });
187
+ });
@@ -0,0 +1,69 @@
1
+ import { useCallback, useEffect, useState, type RefObject } from 'react';
2
+
3
+ import type { Position } from '../types';
4
+ import { POSITION_BOTTOM_RIGHT } from '../types';
5
+ import { calculatePosition, calculateContentPosition } from '../utils/positionUtils';
6
+
7
+ type UseInfoBoxPositionProps = {
8
+ isOpen: boolean;
9
+ triggerRef: RefObject<HTMLElement | null>;
10
+ forcedPosition?: Position;
11
+ };
12
+
13
+ type UseInfoBoxPositionReturn = {
14
+ calculatedPosition: Position;
15
+ contentPosition: { top: number; left: number };
16
+ updateContentPosition: () => void;
17
+ };
18
+
19
+ /**
20
+ * Hook to manage InfoBox positioning relative to trigger and viewport
21
+ */
22
+ export const useInfoBoxPosition = ({
23
+ isOpen,
24
+ triggerRef,
25
+ forcedPosition,
26
+ }: UseInfoBoxPositionProps): UseInfoBoxPositionReturn => {
27
+ const [calculatedPosition, setCalculatedPosition] = useState<Position>(POSITION_BOTTOM_RIGHT);
28
+ const [contentPosition, setContentPosition] = useState({ top: 0, left: 0 });
29
+
30
+ const updateContentPosition = useCallback(() => {
31
+ if (!triggerRef.current) {
32
+ return;
33
+ }
34
+
35
+ const position = calculatePosition(triggerRef.current, forcedPosition);
36
+ const { top, left } = calculateContentPosition(triggerRef.current, position);
37
+
38
+ setCalculatedPosition(position);
39
+ setContentPosition({ top, left });
40
+ }, [triggerRef, forcedPosition]);
41
+
42
+ // Update position when opening
43
+ useEffect(() => {
44
+ if (isOpen) {
45
+ updateContentPosition();
46
+ }
47
+ }, [isOpen, updateContentPosition]);
48
+
49
+ // Handle window resize and scroll while open
50
+ useEffect(() => {
51
+ if (!isOpen) {
52
+ return;
53
+ }
54
+
55
+ window.addEventListener('resize', updateContentPosition);
56
+ window.addEventListener('scroll', updateContentPosition, true);
57
+
58
+ return () => {
59
+ window.removeEventListener('resize', updateContentPosition);
60
+ window.removeEventListener('scroll', updateContentPosition, true);
61
+ };
62
+ }, [isOpen, updateContentPosition]);
63
+
64
+ return {
65
+ calculatedPosition,
66
+ contentPosition,
67
+ updateContentPosition,
68
+ };
69
+ };
@@ -0,0 +1,168 @@
1
+ import { useInfoBoxState } from './useInfoBoxState';
2
+ import { renderHook, act } from '../../../test/renderers';
3
+
4
+ // Helper to avoid deep nesting in tests
5
+ const renderInfoBoxState = (props = {}) => renderHook(() => useInfoBoxState(props));
6
+
7
+ describe('useInfoBoxState', () => {
8
+ describe('initialization', () => {
9
+ it('should start closed by default', () => {
10
+ const { result } = renderInfoBoxState({});
11
+
12
+ expect(result.current.isOpen).toBe(false);
13
+ expect(result.current.isTrapActive).toBe(false);
14
+ });
15
+
16
+ it('should respect defaultOpen prop', () => {
17
+ const { result } = renderInfoBoxState({ defaultOpen: true });
18
+
19
+ expect(result.current.isOpen).toBe(true);
20
+ });
21
+
22
+ it('should initialize refs correctly', () => {
23
+ const { result } = renderInfoBoxState({});
24
+
25
+ expect(result.current.isOpenRef.current).toBe(false);
26
+ expect(result.current.deactivatedByClick.current).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe('opening and closing', () => {
31
+ it('should open when handleOpen is called', () => {
32
+ const { result } = renderInfoBoxState({});
33
+
34
+ act(() => {
35
+ result.current.handleOpen();
36
+ });
37
+
38
+ expect(result.current.isOpen).toBe(true);
39
+ });
40
+
41
+ it('should close when handleClose is called', () => {
42
+ const { result } = renderInfoBoxState({ defaultOpen: true });
43
+
44
+ act(() => {
45
+ result.current.handleClose();
46
+ });
47
+
48
+ expect(result.current.isOpen).toBe(false);
49
+ expect(result.current.isTrapActive).toBe(false);
50
+ });
51
+
52
+ it('should toggle state when toggleOpen is called', () => {
53
+ const { result } = renderInfoBoxState({});
54
+
55
+ act(() => {
56
+ result.current.toggleOpen();
57
+ });
58
+
59
+ expect(result.current.isOpen).toBe(true);
60
+
61
+ act(() => {
62
+ result.current.toggleOpen();
63
+ });
64
+
65
+ expect(result.current.isOpen).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe('focus trap activation', () => {
70
+ it('should activate trap when opened', () => {
71
+ const { result } = renderInfoBoxState({});
72
+
73
+ act(() => {
74
+ result.current.handleOpen();
75
+ });
76
+
77
+ expect(result.current.isTrapActive).toBe(true);
78
+ });
79
+
80
+ it('should deactivate trap when closed', () => {
81
+ const { result } = renderInfoBoxState({ defaultOpen: true });
82
+
83
+ act(() => {
84
+ result.current.handleClose();
85
+ });
86
+
87
+ expect(result.current.isTrapActive).toBe(false);
88
+ });
89
+
90
+ it('should allow manual trap control', () => {
91
+ const { result } = renderInfoBoxState({});
92
+
93
+ act(() => {
94
+ result.current.setIsTrapActive(true);
95
+ });
96
+
97
+ expect(result.current.isTrapActive).toBe(true);
98
+
99
+ act(() => {
100
+ result.current.setIsTrapActive(false);
101
+ });
102
+
103
+ expect(result.current.isTrapActive).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe('callbacks', () => {
108
+ it('should call onOpenChange when opening', () => {
109
+ const onOpenChange = vi.fn();
110
+ const { result } = renderInfoBoxState({ onOpenChange });
111
+
112
+ act(() => {
113
+ result.current.handleOpen();
114
+ });
115
+
116
+ expect(onOpenChange).toHaveBeenCalledWith(true);
117
+ });
118
+
119
+ it('should call onOpenChange when closing', () => {
120
+ const onOpenChange = vi.fn();
121
+ const { result } = renderInfoBoxState({ defaultOpen: true, onOpenChange });
122
+
123
+ act(() => {
124
+ result.current.handleClose();
125
+ });
126
+
127
+ expect(onOpenChange).toHaveBeenCalledWith(false);
128
+ });
129
+
130
+ it('should not call onOpenChange if not provided', () => {
131
+ const { result } = renderInfoBoxState({});
132
+
133
+ // Should not throw
134
+ act(() => {
135
+ result.current.handleOpen();
136
+ });
137
+
138
+ expect(result.current.isOpen).toBe(true);
139
+ });
140
+ });
141
+
142
+ describe('ref synchronization', () => {
143
+ it('should keep isOpenRef in sync with isOpen state', () => {
144
+ const { result, rerender } = renderInfoBoxState({});
145
+
146
+ expect(result.current.isOpenRef.current).toBe(false);
147
+
148
+ act(() => {
149
+ result.current.handleOpen();
150
+ });
151
+
152
+ rerender();
153
+
154
+ expect(result.current.isOpenRef.current).toBe(true);
155
+ });
156
+
157
+ it('should reset deactivatedByClick when state changes', () => {
158
+ const { result } = renderInfoBoxState({});
159
+
160
+ act(() => {
161
+ result.current.deactivatedByClick.current = true;
162
+ result.current.handleOpen();
163
+ });
164
+
165
+ expect(result.current.deactivatedByClick.current).toBe(false);
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,71 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ type UseInfoBoxStateProps = {
4
+ defaultOpen?: boolean;
5
+ onOpenChange?: (isOpen: boolean) => void;
6
+ };
7
+
8
+ type UseInfoBoxStateReturn = {
9
+ isOpen: boolean;
10
+ isTrapActive: boolean;
11
+ setIsTrapActive: (active: boolean) => void;
12
+ isOpenRef: { current: boolean };
13
+ deactivatedByClick: { current: boolean };
14
+ handleOpen: () => void;
15
+ handleClose: () => void;
16
+ toggleOpen: () => void;
17
+ };
18
+
19
+ /**
20
+ * Hook to manage InfoBox open/close state and focus trap lifecycle
21
+ */
22
+ export const useInfoBoxState = ({
23
+ defaultOpen = false,
24
+ onOpenChange,
25
+ }: UseInfoBoxStateProps): UseInfoBoxStateReturn => {
26
+ const [isOpen, setIsOpen] = useState(defaultOpen);
27
+ const [isTrapActive, setIsTrapActive] = useState(false);
28
+
29
+ const isOpenRef = useRef(isOpen);
30
+ const deactivatedByClick = useRef(false);
31
+
32
+ // Keep ref in sync with state
33
+ useEffect(() => {
34
+ isOpenRef.current = isOpen;
35
+ deactivatedByClick.current = false;
36
+
37
+ if (isOpen) {
38
+ setIsTrapActive(true);
39
+ }
40
+ }, [isOpen]);
41
+
42
+ const handleOpen = useCallback(() => {
43
+ setIsOpen(true);
44
+ onOpenChange?.(true);
45
+ }, [onOpenChange]);
46
+
47
+ const handleClose = useCallback(() => {
48
+ setIsOpen(false);
49
+ setIsTrapActive(false);
50
+ onOpenChange?.(false);
51
+ }, [onOpenChange]);
52
+
53
+ const toggleOpen = useCallback(() => {
54
+ if (isOpen) {
55
+ handleClose();
56
+ } else {
57
+ handleOpen();
58
+ }
59
+ }, [isOpen, handleOpen, handleClose]);
60
+
61
+ return {
62
+ isOpen,
63
+ isTrapActive,
64
+ setIsTrapActive,
65
+ isOpenRef,
66
+ deactivatedByClick,
67
+ handleOpen,
68
+ handleClose,
69
+ toggleOpen,
70
+ };
71
+ };
@@ -0,0 +1,62 @@
1
+ import { usePortalMount } from './usePortalMount';
2
+ import { renderHook } from '../../../test/renderers';
3
+
4
+ // Helper to avoid deep nesting in tests
5
+ const renderPortalMount = () => renderHook(() => usePortalMount());
6
+
7
+ describe('usePortalMount', () => {
8
+ describe('initialization', () => {
9
+ it('should return false initially in SSR context', () => {
10
+ const { result } = renderPortalMount();
11
+
12
+ // In test environment, useEffect runs immediately, so it will be true
13
+ // In real SSR, this would be false on server and true on client
14
+ expect(result.current).toBe(true);
15
+ });
16
+ });
17
+
18
+ describe('mounting', () => {
19
+ it('should return true after mount', () => {
20
+ const { result, rerender } = renderPortalMount();
21
+
22
+ // In test environment, useEffect runs immediately
23
+ expect(result.current).toBe(true);
24
+
25
+ // Trigger effect by rerendering
26
+ rerender();
27
+
28
+ // Should remain true
29
+ expect(result.current).toBe(true);
30
+ });
31
+
32
+ it('should remain true on subsequent rerenders', () => {
33
+ const { result, rerender } = renderPortalMount();
34
+
35
+ rerender();
36
+ expect(result.current).toBe(true);
37
+
38
+ rerender();
39
+ expect(result.current).toBe(true);
40
+ });
41
+ });
42
+
43
+ describe('SSR safety', () => {
44
+ it('should not throw when rendered', () => {
45
+ expect(() => renderPortalMount()).not.toThrow();
46
+ });
47
+
48
+ it('should always return boolean', () => {
49
+ const { result } = renderPortalMount();
50
+
51
+ expect(typeof result.current).toBe('boolean');
52
+ });
53
+ });
54
+
55
+ describe('cleanup', () => {
56
+ it('should not throw on unmount', () => {
57
+ const { unmount } = renderPortalMount();
58
+
59
+ expect(() => unmount()).not.toThrow();
60
+ });
61
+ });
62
+ });
@@ -0,0 +1,15 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Hook to handle SSR-safe portal mounting
5
+ * Ensures portal only renders after component mounts on client
6
+ */
7
+ export const usePortalMount = (): boolean => {
8
+ const [isMounted, setIsMounted] = useState(false);
9
+
10
+ useEffect(() => {
11
+ setIsMounted(true);
12
+ }, []);
13
+
14
+ return isMounted;
15
+ };
@@ -0,0 +1,6 @@
1
+ export type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
2
+
3
+ export const POSITION_TOP_LEFT: Position = 'top-left';
4
+ export const POSITION_TOP_RIGHT: Position = 'top-right';
5
+ export const POSITION_BOTTOM_LEFT: Position = 'bottom-left';
6
+ export const POSITION_BOTTOM_RIGHT: Position = 'bottom-right';