@tpzdsp/next-toolkit 1.13.0 → 1.14.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.
- package/package.json +13 -1
- package/src/components/ButtonLink/ButtonLink.stories.tsx +72 -0
- package/src/components/ButtonLink/ButtonLink.test.tsx +154 -0
- package/src/components/ButtonLink/ButtonLink.tsx +33 -0
- package/src/components/InfoBox/InfoBox.stories.tsx +31 -28
- package/src/components/InfoBox/InfoBox.test.tsx +8 -60
- package/src/components/InfoBox/InfoBox.tsx +60 -69
- package/src/components/LinkButton/LinkButton.stories.tsx +74 -0
- package/src/components/LinkButton/LinkButton.test.tsx +177 -0
- package/src/components/LinkButton/LinkButton.tsx +80 -0
- package/src/components/index.ts +5 -8
- package/src/components/link/ExternalLink.test.tsx +104 -0
- package/src/components/link/ExternalLink.tsx +1 -0
- package/src/map/MapComponent.tsx +7 -12
- package/src/components/InfoBox/hooks/index.ts +0 -3
- package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +0 -187
- package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +0 -69
- package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +0 -168
- package/src/components/InfoBox/hooks/useInfoBoxState.ts +0 -71
- package/src/components/InfoBox/hooks/usePortalMount.test.ts +0 -62
- package/src/components/InfoBox/hooks/usePortalMount.ts +0 -15
- package/src/components/InfoBox/utils/focusTrapConfig.test.ts +0 -310
- package/src/components/InfoBox/utils/focusTrapConfig.ts +0 -59
- package/src/components/InfoBox/utils/index.ts +0 -2
- package/src/components/InfoBox/utils/positionUtils.test.ts +0 -170
- package/src/components/InfoBox/utils/positionUtils.ts +0 -89
package/src/map/MapComponent.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { memo, useEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { Map, Overlay, View } from 'ol';
|
|
6
6
|
import { Attribution, ScaleLine, Zoom } from 'ol/control';
|
|
@@ -17,7 +17,6 @@ import type { PopupDirection } from '../types/map';
|
|
|
17
17
|
export type MapComponentProps = {
|
|
18
18
|
osMapsApiKey?: string;
|
|
19
19
|
basePath: string;
|
|
20
|
-
isLoading?: boolean;
|
|
21
20
|
};
|
|
22
21
|
|
|
23
22
|
const positionTransforms: Record<PopupDirection, string> = {
|
|
@@ -39,9 +38,12 @@ const arrowStyles: Record<PopupDirection, string> = {
|
|
|
39
38
|
* and basic controls are added to the map. The map component encapsulates
|
|
40
39
|
* a number of child components, used to interact with the map itself.
|
|
41
40
|
*
|
|
41
|
+
* Memoized to prevent unnecessary re-renders that could conflict with
|
|
42
|
+
* OpenLayers' imperative DOM manipulation.
|
|
43
|
+
*
|
|
42
44
|
* @return {*}
|
|
43
45
|
*/
|
|
44
|
-
|
|
46
|
+
const MapComponentBase = ({ osMapsApiKey, basePath }: MapComponentProps) => {
|
|
45
47
|
const [popupFeatures, setPopupFeatures] = useState([]);
|
|
46
48
|
const [popupCoordinate, setPopupCoordinate] = useState<number[] | null>(null);
|
|
47
49
|
const [popupPositionClass, setPopupPositionClass] = useState<PopupDirection>('bottom-right');
|
|
@@ -155,15 +157,6 @@ export const MapComponent = ({ osMapsApiKey, basePath, isLoading }: MapComponent
|
|
|
155
157
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
|
156
158
|
tabIndex={0}
|
|
157
159
|
>
|
|
158
|
-
{isLoading ? (
|
|
159
|
-
<div className="absolute inset-0 flex items-center justify-center bg-white/50 z-10">
|
|
160
|
-
<div
|
|
161
|
-
className="w-9 h-9 border-4 border-gray-200 border-t-blue-500 rounded-full
|
|
162
|
-
animate-spin"
|
|
163
|
-
/>
|
|
164
|
-
</div>
|
|
165
|
-
) : null}
|
|
166
|
-
|
|
167
160
|
<div
|
|
168
161
|
className={`absolute z-20 ${positionTransforms[popupPositionClass]}`}
|
|
169
162
|
id="popup-container"
|
|
@@ -182,3 +175,5 @@ export const MapComponent = ({ osMapsApiKey, basePath, isLoading }: MapComponent
|
|
|
182
175
|
</div>
|
|
183
176
|
);
|
|
184
177
|
};
|
|
178
|
+
|
|
179
|
+
export const MapComponent = memo(MapComponentBase);
|
|
@@ -1,187 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,168 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
};
|