@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
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import type { RefObject } from 'react';
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
-
|
|
5
|
-
import { getFocusTrapConfig } from './focusTrapConfig';
|
|
6
|
-
|
|
7
|
-
// Helper to create config with proper type casting
|
|
8
|
-
const createConfig = (
|
|
9
|
-
mockTriggerRef: { current: HTMLElement | null },
|
|
10
|
-
mockContentRef: { current: HTMLElement | null },
|
|
11
|
-
mockIsOpenRef: { current: boolean },
|
|
12
|
-
mockDeactivatedByClick: { current: boolean },
|
|
13
|
-
mockHandleClose: () => void,
|
|
14
|
-
mockSetIsTrapActive: (active: boolean) => void,
|
|
15
|
-
) =>
|
|
16
|
-
getFocusTrapConfig({
|
|
17
|
-
isOpenRef: mockIsOpenRef,
|
|
18
|
-
deactivatedByClick: mockDeactivatedByClick,
|
|
19
|
-
triggerRef: mockTriggerRef as RefObject<HTMLElement>,
|
|
20
|
-
contentRef: mockContentRef as RefObject<HTMLElement>,
|
|
21
|
-
handleClose: mockHandleClose,
|
|
22
|
-
setIsTrapActive: mockSetIsTrapActive,
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe('focusTrapConfig', () => {
|
|
26
|
-
let mockTriggerRef: { current: HTMLElement | null };
|
|
27
|
-
let mockContentRef: { current: HTMLElement | null };
|
|
28
|
-
let mockIsOpenRef: { current: boolean };
|
|
29
|
-
let mockDeactivatedByClick: { current: boolean };
|
|
30
|
-
let mockHandleClose: () => void;
|
|
31
|
-
let mockSetIsTrapActive: (active: boolean) => void;
|
|
32
|
-
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
const triggerElement = document.createElement('button');
|
|
35
|
-
const contentElement = document.createElement('div');
|
|
36
|
-
|
|
37
|
-
mockTriggerRef = { current: triggerElement };
|
|
38
|
-
mockContentRef = { current: contentElement };
|
|
39
|
-
mockIsOpenRef = { current: true };
|
|
40
|
-
mockDeactivatedByClick = { current: false };
|
|
41
|
-
mockHandleClose = vi.fn();
|
|
42
|
-
mockSetIsTrapActive = vi.fn();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('should return configuration object with all required properties', () => {
|
|
46
|
-
const config = createConfig(
|
|
47
|
-
mockTriggerRef,
|
|
48
|
-
mockContentRef,
|
|
49
|
-
mockIsOpenRef,
|
|
50
|
-
mockDeactivatedByClick,
|
|
51
|
-
mockHandleClose,
|
|
52
|
-
mockSetIsTrapActive,
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
expect(config).toHaveProperty('escapeDeactivates');
|
|
56
|
-
expect(config).toHaveProperty('clickOutsideDeactivates');
|
|
57
|
-
expect(config).toHaveProperty('allowOutsideClick');
|
|
58
|
-
expect(config).toHaveProperty('setReturnFocus');
|
|
59
|
-
expect(config).toHaveProperty('onDeactivate');
|
|
60
|
-
expect(config).toHaveProperty('onActivate');
|
|
61
|
-
expect(config).toHaveProperty('fallbackFocus');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('escapeDeactivates', () => {
|
|
65
|
-
it('should call handleClose when open', () => {
|
|
66
|
-
const config = createConfig(
|
|
67
|
-
mockTriggerRef,
|
|
68
|
-
mockContentRef,
|
|
69
|
-
mockIsOpenRef,
|
|
70
|
-
mockDeactivatedByClick,
|
|
71
|
-
mockHandleClose,
|
|
72
|
-
mockSetIsTrapActive,
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const result = (config.escapeDeactivates as () => boolean)();
|
|
76
|
-
|
|
77
|
-
expect(mockHandleClose).toHaveBeenCalled();
|
|
78
|
-
expect(mockDeactivatedByClick.current).toBe(false);
|
|
79
|
-
expect(result).toBe(false);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('should not call handleClose when closed', () => {
|
|
83
|
-
mockIsOpenRef.current = false;
|
|
84
|
-
|
|
85
|
-
const config = createConfig(
|
|
86
|
-
mockTriggerRef,
|
|
87
|
-
mockContentRef,
|
|
88
|
-
mockIsOpenRef,
|
|
89
|
-
mockDeactivatedByClick,
|
|
90
|
-
mockHandleClose,
|
|
91
|
-
mockSetIsTrapActive,
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
(config.escapeDeactivates as () => boolean)();
|
|
95
|
-
|
|
96
|
-
expect(mockHandleClose).not.toHaveBeenCalled();
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe('clickOutsideDeactivates', () => {
|
|
101
|
-
it('should return false for clicks on trigger', () => {
|
|
102
|
-
const config = createConfig(
|
|
103
|
-
mockTriggerRef,
|
|
104
|
-
mockContentRef,
|
|
105
|
-
mockIsOpenRef,
|
|
106
|
-
mockDeactivatedByClick,
|
|
107
|
-
mockHandleClose,
|
|
108
|
-
mockSetIsTrapActive,
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
const event = new MouseEvent('click');
|
|
112
|
-
|
|
113
|
-
Object.defineProperty(event, 'target', { value: mockTriggerRef.current, configurable: true });
|
|
114
|
-
|
|
115
|
-
const result = (config.clickOutsideDeactivates as (e: MouseEvent) => boolean)(event);
|
|
116
|
-
|
|
117
|
-
expect(result).toBe(false);
|
|
118
|
-
expect(mockDeactivatedByClick.current).toBe(false);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('should return true for clicks outside', () => {
|
|
122
|
-
const config = createConfig(
|
|
123
|
-
mockTriggerRef,
|
|
124
|
-
mockContentRef,
|
|
125
|
-
mockIsOpenRef,
|
|
126
|
-
mockDeactivatedByClick,
|
|
127
|
-
mockHandleClose,
|
|
128
|
-
mockSetIsTrapActive,
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const outsideElement = document.createElement('div');
|
|
132
|
-
const event = new MouseEvent('click');
|
|
133
|
-
|
|
134
|
-
Object.defineProperty(event, 'target', { value: outsideElement, configurable: true });
|
|
135
|
-
|
|
136
|
-
const result = (config.clickOutsideDeactivates as (e: MouseEvent) => boolean)(event);
|
|
137
|
-
|
|
138
|
-
expect(result).toBe(true);
|
|
139
|
-
expect(mockDeactivatedByClick.current).toBe(true);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe('allowOutsideClick', () => {
|
|
144
|
-
it('should return true for clicks on trigger', () => {
|
|
145
|
-
const config = createConfig(
|
|
146
|
-
mockTriggerRef,
|
|
147
|
-
mockContentRef,
|
|
148
|
-
mockIsOpenRef,
|
|
149
|
-
mockDeactivatedByClick,
|
|
150
|
-
mockHandleClose,
|
|
151
|
-
mockSetIsTrapActive,
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
const event = new MouseEvent('click');
|
|
155
|
-
|
|
156
|
-
Object.defineProperty(event, 'target', { value: mockTriggerRef.current, configurable: true });
|
|
157
|
-
|
|
158
|
-
const result = (config.allowOutsideClick as (e: MouseEvent) => boolean)(event);
|
|
159
|
-
|
|
160
|
-
expect(result).toBe(true);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('should return false for clicks elsewhere', () => {
|
|
164
|
-
const config = createConfig(
|
|
165
|
-
mockTriggerRef,
|
|
166
|
-
mockContentRef,
|
|
167
|
-
mockIsOpenRef,
|
|
168
|
-
mockDeactivatedByClick,
|
|
169
|
-
mockHandleClose,
|
|
170
|
-
mockSetIsTrapActive,
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
const outsideElement = document.createElement('div');
|
|
174
|
-
const event = new MouseEvent('click');
|
|
175
|
-
|
|
176
|
-
Object.defineProperty(event, 'target', { value: outsideElement, configurable: true });
|
|
177
|
-
|
|
178
|
-
const result = (config.allowOutsideClick as (e: MouseEvent) => boolean)(event);
|
|
179
|
-
|
|
180
|
-
expect(result).toBe(false);
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
describe('setReturnFocus', () => {
|
|
185
|
-
it('should return false when deactivated by click', () => {
|
|
186
|
-
mockDeactivatedByClick.current = true;
|
|
187
|
-
|
|
188
|
-
const config = createConfig(
|
|
189
|
-
mockTriggerRef,
|
|
190
|
-
mockContentRef,
|
|
191
|
-
mockIsOpenRef,
|
|
192
|
-
mockDeactivatedByClick,
|
|
193
|
-
mockHandleClose,
|
|
194
|
-
mockSetIsTrapActive,
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
const target = document.createElement('button');
|
|
198
|
-
const result = (config.setReturnFocus as (target: HTMLElement) => HTMLElement | false)(
|
|
199
|
-
target,
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
expect(result).toBe(false);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('should return target when not deactivated by click', () => {
|
|
206
|
-
const config = createConfig(
|
|
207
|
-
mockTriggerRef,
|
|
208
|
-
mockContentRef,
|
|
209
|
-
mockIsOpenRef,
|
|
210
|
-
mockDeactivatedByClick,
|
|
211
|
-
mockHandleClose,
|
|
212
|
-
mockSetIsTrapActive,
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
const target = document.createElement('button');
|
|
216
|
-
const result = (config.setReturnFocus as (target: HTMLElement) => HTMLElement | false)(
|
|
217
|
-
target,
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
expect(result).toBe(target);
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
describe('onDeactivate', () => {
|
|
225
|
-
it('should call setIsTrapActive and handleClose', () => {
|
|
226
|
-
const config = createConfig(
|
|
227
|
-
mockTriggerRef,
|
|
228
|
-
mockContentRef,
|
|
229
|
-
mockIsOpenRef,
|
|
230
|
-
mockDeactivatedByClick,
|
|
231
|
-
mockHandleClose,
|
|
232
|
-
mockSetIsTrapActive,
|
|
233
|
-
);
|
|
234
|
-
|
|
235
|
-
(config.onDeactivate as () => void)();
|
|
236
|
-
|
|
237
|
-
expect(mockSetIsTrapActive).toHaveBeenCalledWith(false);
|
|
238
|
-
expect(mockHandleClose).toHaveBeenCalled();
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
describe('onActivate', () => {
|
|
243
|
-
it('should call setIsTrapActive with true', () => {
|
|
244
|
-
const config = createConfig(
|
|
245
|
-
mockTriggerRef,
|
|
246
|
-
mockContentRef,
|
|
247
|
-
mockIsOpenRef,
|
|
248
|
-
mockDeactivatedByClick,
|
|
249
|
-
mockHandleClose,
|
|
250
|
-
mockSetIsTrapActive,
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
(config.onActivate as () => void)();
|
|
254
|
-
|
|
255
|
-
expect(mockSetIsTrapActive).toHaveBeenCalledWith(true);
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
describe('fallbackFocus', () => {
|
|
260
|
-
it('should return contentRef when available', () => {
|
|
261
|
-
const config = createConfig(
|
|
262
|
-
mockTriggerRef,
|
|
263
|
-
mockContentRef,
|
|
264
|
-
mockIsOpenRef,
|
|
265
|
-
mockDeactivatedByClick,
|
|
266
|
-
mockHandleClose,
|
|
267
|
-
mockSetIsTrapActive,
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
const result = (config.fallbackFocus as () => HTMLElement)();
|
|
271
|
-
|
|
272
|
-
expect(result).toBe(mockContentRef.current);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it('should return triggerRef when contentRef is null', () => {
|
|
276
|
-
mockContentRef.current = null;
|
|
277
|
-
|
|
278
|
-
const config = createConfig(
|
|
279
|
-
mockTriggerRef,
|
|
280
|
-
mockContentRef,
|
|
281
|
-
mockIsOpenRef,
|
|
282
|
-
mockDeactivatedByClick,
|
|
283
|
-
mockHandleClose,
|
|
284
|
-
mockSetIsTrapActive,
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
const result = (config.fallbackFocus as () => HTMLElement)();
|
|
288
|
-
|
|
289
|
-
expect(result).toBe(mockTriggerRef.current);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('should return document.body when both refs are null', () => {
|
|
293
|
-
mockContentRef.current = null;
|
|
294
|
-
mockTriggerRef.current = null;
|
|
295
|
-
|
|
296
|
-
const config = createConfig(
|
|
297
|
-
mockTriggerRef,
|
|
298
|
-
mockContentRef,
|
|
299
|
-
mockIsOpenRef,
|
|
300
|
-
mockDeactivatedByClick,
|
|
301
|
-
mockHandleClose,
|
|
302
|
-
mockSetIsTrapActive,
|
|
303
|
-
);
|
|
304
|
-
|
|
305
|
-
const result = (config.fallbackFocus as () => HTMLElement)();
|
|
306
|
-
|
|
307
|
-
expect(result).toBe(document.body);
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
});
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import type { RefObject } from 'react';
|
|
2
|
-
|
|
3
|
-
import type { Options as FocusTrapOptions } from 'focus-trap';
|
|
4
|
-
|
|
5
|
-
type FocusTrapConfigProps = {
|
|
6
|
-
isOpenRef: { current: boolean };
|
|
7
|
-
deactivatedByClick: { current: boolean };
|
|
8
|
-
triggerRef: RefObject<HTMLElement | null>;
|
|
9
|
-
contentRef: RefObject<HTMLElement | null>;
|
|
10
|
-
handleClose: () => void;
|
|
11
|
-
setIsTrapActive: (active: boolean) => void;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Generate focus trap configuration options
|
|
16
|
-
*/
|
|
17
|
-
export const getFocusTrapConfig = ({
|
|
18
|
-
isOpenRef,
|
|
19
|
-
deactivatedByClick,
|
|
20
|
-
triggerRef,
|
|
21
|
-
contentRef,
|
|
22
|
-
handleClose,
|
|
23
|
-
setIsTrapActive,
|
|
24
|
-
}: FocusTrapConfigProps): FocusTrapOptions => ({
|
|
25
|
-
escapeDeactivates: () => {
|
|
26
|
-
deactivatedByClick.current = false;
|
|
27
|
-
|
|
28
|
-
if (isOpenRef.current) {
|
|
29
|
-
handleClose();
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return false;
|
|
33
|
-
},
|
|
34
|
-
clickOutsideDeactivates: (e) => {
|
|
35
|
-
// Don't let focus trap handle trigger button clicks - let the button handle it
|
|
36
|
-
if (e.target && triggerRef.current?.contains(e.target as Node)) {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
deactivatedByClick.current = true;
|
|
41
|
-
|
|
42
|
-
return true;
|
|
43
|
-
},
|
|
44
|
-
allowOutsideClick: (e) => {
|
|
45
|
-
// Allow clicks on the trigger button to pass through
|
|
46
|
-
return !!(e.target && triggerRef.current?.contains(e.target as Node));
|
|
47
|
-
},
|
|
48
|
-
// eslint-disable-next-line sonarjs/function-return-type
|
|
49
|
-
setReturnFocus: (target) => {
|
|
50
|
-
// Don't return focus if user clicked outside - it would steal focus from their click target
|
|
51
|
-
return deactivatedByClick.current ? false : target;
|
|
52
|
-
},
|
|
53
|
-
onDeactivate: () => {
|
|
54
|
-
setIsTrapActive(false);
|
|
55
|
-
handleClose();
|
|
56
|
-
},
|
|
57
|
-
onActivate: () => setIsTrapActive(true),
|
|
58
|
-
fallbackFocus: () => contentRef.current ?? triggerRef.current ?? document.body,
|
|
59
|
-
});
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
POSITION_TOP_LEFT,
|
|
3
|
-
POSITION_TOP_RIGHT,
|
|
4
|
-
POSITION_BOTTOM_LEFT,
|
|
5
|
-
POSITION_BOTTOM_RIGHT,
|
|
6
|
-
} from '../types';
|
|
7
|
-
import { calculatePosition, calculateContentPosition, getTransformClasses } from './positionUtils';
|
|
8
|
-
|
|
9
|
-
// Mock factory helper to avoid deep nesting
|
|
10
|
-
const createMockRect = (top: number, left: number, width = 24, height = 24) => ({
|
|
11
|
-
top,
|
|
12
|
-
left,
|
|
13
|
-
bottom: top + height,
|
|
14
|
-
right: left + width,
|
|
15
|
-
width,
|
|
16
|
-
height,
|
|
17
|
-
x: left,
|
|
18
|
-
y: top,
|
|
19
|
-
toJSON: () => ({}),
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('positionUtils', () => {
|
|
23
|
-
let mockElement: HTMLElement;
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
mockElement = document.createElement('button');
|
|
27
|
-
|
|
28
|
-
// Mock window dimensions
|
|
29
|
-
Object.defineProperty(globalThis, 'innerWidth', {
|
|
30
|
-
value: 1000,
|
|
31
|
-
writable: true,
|
|
32
|
-
configurable: true,
|
|
33
|
-
});
|
|
34
|
-
Object.defineProperty(globalThis, 'innerHeight', {
|
|
35
|
-
value: 800,
|
|
36
|
-
writable: true,
|
|
37
|
-
configurable: true,
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('calculatePosition', () => {
|
|
42
|
-
it('should return forced position when provided', () => {
|
|
43
|
-
mockElement.getBoundingClientRect = vi.fn(() => createMockRect(100, 100));
|
|
44
|
-
|
|
45
|
-
const result = calculatePosition(mockElement, POSITION_TOP_LEFT);
|
|
46
|
-
|
|
47
|
-
expect(result).toBe(POSITION_TOP_LEFT);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('should calculate bottom-right for top-left quadrant', () => {
|
|
51
|
-
mockElement.getBoundingClientRect = vi.fn(() => createMockRect(100, 100));
|
|
52
|
-
|
|
53
|
-
const result = calculatePosition(mockElement);
|
|
54
|
-
|
|
55
|
-
// top=100 < 400 (half of 800) -> bottom
|
|
56
|
-
// left=100 < 500 (half of 1000) -> right
|
|
57
|
-
expect(result).toBe(POSITION_BOTTOM_RIGHT);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('should calculate top-left for bottom-right quadrant', () => {
|
|
61
|
-
mockElement.getBoundingClientRect = vi.fn(() => createMockRect(700, 800));
|
|
62
|
-
|
|
63
|
-
const result = calculatePosition(mockElement);
|
|
64
|
-
|
|
65
|
-
// top=700 >= 400 (half of 800) -> top
|
|
66
|
-
// left=800 >= 500 (half of 1000) -> left
|
|
67
|
-
expect(result).toBe(POSITION_TOP_LEFT);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('should calculate bottom-left for top-right quadrant', () => {
|
|
71
|
-
mockElement.getBoundingClientRect = vi.fn(() => createMockRect(100, 800));
|
|
72
|
-
|
|
73
|
-
const result = calculatePosition(mockElement);
|
|
74
|
-
|
|
75
|
-
// top=100 < 400 (half of 800) -> bottom
|
|
76
|
-
// left=800 >= 500 (half of 1000) -> left
|
|
77
|
-
expect(result).toBe(POSITION_BOTTOM_LEFT);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('should calculate top-right for bottom-left quadrant', () => {
|
|
81
|
-
mockElement.getBoundingClientRect = vi.fn(() => createMockRect(700, 100));
|
|
82
|
-
|
|
83
|
-
const result = calculatePosition(mockElement);
|
|
84
|
-
|
|
85
|
-
// top=700 >= 400 (half of 800) -> top
|
|
86
|
-
// left=100 < 500 (half of 1000) -> right
|
|
87
|
-
expect(result).toBe(POSITION_TOP_RIGHT);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe('calculateContentPosition', () => {
|
|
92
|
-
beforeEach(() => {
|
|
93
|
-
mockElement.getBoundingClientRect = vi.fn(() => createMockRect(100, 100));
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('should calculate position for bottom-right', () => {
|
|
97
|
-
const result = calculateContentPosition(mockElement, POSITION_BOTTOM_RIGHT);
|
|
98
|
-
|
|
99
|
-
// bottom (124) + offset (8) = 132
|
|
100
|
-
// left = 100
|
|
101
|
-
expect(result.top).toBe(132);
|
|
102
|
-
expect(result.left).toBe(100);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('should calculate position for bottom-left', () => {
|
|
106
|
-
const result = calculateContentPosition(mockElement, POSITION_BOTTOM_LEFT);
|
|
107
|
-
|
|
108
|
-
// bottom (124) + offset (8) = 132
|
|
109
|
-
// right = 124
|
|
110
|
-
expect(result.top).toBe(132);
|
|
111
|
-
expect(result.left).toBe(124);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('should calculate position for top-right', () => {
|
|
115
|
-
const result = calculateContentPosition(mockElement, POSITION_TOP_RIGHT);
|
|
116
|
-
|
|
117
|
-
// top (100) - offset (8) = 92
|
|
118
|
-
// left = 100
|
|
119
|
-
expect(result.top).toBe(92);
|
|
120
|
-
expect(result.left).toBe(100);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('should calculate position for top-left', () => {
|
|
124
|
-
const result = calculateContentPosition(mockElement, POSITION_TOP_LEFT);
|
|
125
|
-
|
|
126
|
-
// top (100) - offset (8) = 92
|
|
127
|
-
// right = 124
|
|
128
|
-
expect(result.top).toBe(92);
|
|
129
|
-
expect(result.left).toBe(124);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('should clamp to minimum viewport padding', () => {
|
|
133
|
-
mockElement.getBoundingClientRect = vi.fn(() => createMockRect(5, 5));
|
|
134
|
-
|
|
135
|
-
const result = calculateContentPosition(mockElement, 'top-left');
|
|
136
|
-
|
|
137
|
-
// Should be clamped to minimum 16px from edge
|
|
138
|
-
expect(result.top).toBeGreaterThanOrEqual(16);
|
|
139
|
-
expect(result.left).toBeGreaterThanOrEqual(16);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('should clamp to maximum viewport bounds', () => {
|
|
143
|
-
mockElement.getBoundingClientRect = vi.fn(() => createMockRect(790, 990));
|
|
144
|
-
|
|
145
|
-
const result = calculateContentPosition(mockElement, 'bottom-right');
|
|
146
|
-
|
|
147
|
-
// Should be clamped to maximum (viewport - 16px padding)
|
|
148
|
-
expect(result.top).toBeLessThanOrEqual(800 - 16);
|
|
149
|
-
expect(result.left).toBeLessThanOrEqual(1000 - 16);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe('getTransformClasses', () => {
|
|
154
|
-
it('should return empty string for bottom-right', () => {
|
|
155
|
-
expect(getTransformClasses(POSITION_BOTTOM_RIGHT)).toBe('');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('should return x-transform for bottom-left', () => {
|
|
159
|
-
expect(getTransformClasses(POSITION_BOTTOM_LEFT)).toBe('-translate-x-full');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('should return y-transform for top-right', () => {
|
|
163
|
-
expect(getTransformClasses(POSITION_TOP_RIGHT)).toBe('-translate-y-full');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('should return both transforms for top-left', () => {
|
|
167
|
-
expect(getTransformClasses(POSITION_TOP_LEFT)).toBe('-translate-x-full -translate-y-full');
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
});
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { Position } from '../types';
|
|
2
|
-
import {
|
|
3
|
-
POSITION_TOP_LEFT,
|
|
4
|
-
POSITION_TOP_RIGHT,
|
|
5
|
-
POSITION_BOTTOM_LEFT,
|
|
6
|
-
POSITION_BOTTOM_RIGHT,
|
|
7
|
-
} from '../types';
|
|
8
|
-
|
|
9
|
-
const OFFSET = 8; // gap between trigger and content
|
|
10
|
-
const VIEWPORT_PADDING = 16; // minimum distance from viewport edges
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Calculate optimal position based on trigger location in viewport
|
|
14
|
-
*/
|
|
15
|
-
export const calculatePosition = (
|
|
16
|
-
triggerElement: HTMLElement,
|
|
17
|
-
forcedPosition?: Position,
|
|
18
|
-
): Position => {
|
|
19
|
-
if (forcedPosition) {
|
|
20
|
-
return forcedPosition;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const rect = triggerElement.getBoundingClientRect();
|
|
24
|
-
const viewportWidth = window.innerWidth;
|
|
25
|
-
const viewportHeight = window.innerHeight;
|
|
26
|
-
|
|
27
|
-
const horizontal = rect.left < viewportWidth / 2 ? 'right' : 'left';
|
|
28
|
-
const vertical = rect.top < viewportHeight / 2 ? 'bottom' : 'top';
|
|
29
|
-
|
|
30
|
-
return `${vertical}-${horizontal}` as Position;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Calculate absolute position for portal content with viewport clamping
|
|
35
|
-
*/
|
|
36
|
-
export const calculateContentPosition = (
|
|
37
|
-
triggerElement: HTMLElement,
|
|
38
|
-
position: Position,
|
|
39
|
-
): { top: number; left: number } => {
|
|
40
|
-
const rect = triggerElement.getBoundingClientRect();
|
|
41
|
-
let top: number;
|
|
42
|
-
let left: number;
|
|
43
|
-
|
|
44
|
-
switch (position) {
|
|
45
|
-
case POSITION_BOTTOM_LEFT:
|
|
46
|
-
top = rect.bottom + OFFSET;
|
|
47
|
-
left = rect.right;
|
|
48
|
-
break;
|
|
49
|
-
case POSITION_TOP_RIGHT:
|
|
50
|
-
top = rect.top - OFFSET;
|
|
51
|
-
left = rect.left;
|
|
52
|
-
break;
|
|
53
|
-
case POSITION_TOP_LEFT:
|
|
54
|
-
top = rect.top - OFFSET;
|
|
55
|
-
left = rect.right;
|
|
56
|
-
break;
|
|
57
|
-
case POSITION_BOTTOM_RIGHT:
|
|
58
|
-
default:
|
|
59
|
-
// POSITION_BOTTOM_RIGHT (default behavior)
|
|
60
|
-
top = rect.bottom + OFFSET;
|
|
61
|
-
left = rect.left;
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Clamp to viewport bounds to prevent overflow
|
|
66
|
-
const maxLeft = window.innerWidth - VIEWPORT_PADDING;
|
|
67
|
-
const maxTop = window.innerHeight - VIEWPORT_PADDING;
|
|
68
|
-
|
|
69
|
-
left = Math.max(VIEWPORT_PADDING, Math.min(left, maxLeft));
|
|
70
|
-
top = Math.max(VIEWPORT_PADDING, Math.min(top, maxTop));
|
|
71
|
-
|
|
72
|
-
return { top, left };
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Get Tailwind transform classes based on position
|
|
77
|
-
*/
|
|
78
|
-
export const getTransformClasses = (position: Position): string => {
|
|
79
|
-
switch (position) {
|
|
80
|
-
case POSITION_BOTTOM_LEFT:
|
|
81
|
-
return '-translate-x-full';
|
|
82
|
-
case POSITION_TOP_RIGHT:
|
|
83
|
-
return '-translate-y-full';
|
|
84
|
-
case POSITION_TOP_LEFT:
|
|
85
|
-
return '-translate-x-full -translate-y-full';
|
|
86
|
-
default:
|
|
87
|
-
return '';
|
|
88
|
-
}
|
|
89
|
-
};
|