@tpzdsp/next-toolkit 1.12.0 → 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.
- package/README.md +4 -4
- package/package.json +1 -6
- package/src/assets/styles/globals.css +21 -0
- package/src/assets/styles/ol.css +147 -176
- package/src/components/InfoBox/InfoBox.stories.tsx +457 -0
- package/src/components/InfoBox/InfoBox.test.tsx +382 -0
- package/src/components/InfoBox/InfoBox.tsx +177 -0
- package/src/components/InfoBox/hooks/index.ts +3 -0
- package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +187 -0
- package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +69 -0
- package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +168 -0
- package/src/components/InfoBox/hooks/useInfoBoxState.ts +71 -0
- package/src/components/InfoBox/hooks/usePortalMount.test.ts +62 -0
- package/src/components/InfoBox/hooks/usePortalMount.ts +15 -0
- package/src/components/InfoBox/types.ts +6 -0
- package/src/components/InfoBox/utils/focusTrapConfig.test.ts +310 -0
- package/src/components/InfoBox/utils/focusTrapConfig.ts +59 -0
- package/src/components/InfoBox/utils/index.ts +2 -0
- package/src/components/InfoBox/utils/positionUtils.test.ts +170 -0
- package/src/components/InfoBox/utils/positionUtils.ts +89 -0
- package/src/components/index.ts +8 -0
- package/src/http/logger.ts +1 -1
- package/src/map/FullScreenControl.ts +126 -0
- package/src/map/LayerSwitcherControl.ts +87 -181
- package/src/map/LayerSwitcherPanel.tsx +173 -0
- package/src/map/MapComponent.tsx +6 -35
- package/src/map/createControlButton.ts +72 -0
- package/src/map/geocoder/Geocoder.test.tsx +115 -0
- package/src/map/geocoder/Geocoder.tsx +393 -0
- package/src/map/geocoder/groupResults.ts +12 -0
- package/src/map/geocoder/index.ts +4 -0
- package/src/map/geocoder/types.ts +11 -0
- package/src/map/geometries.ts +7 -1
- package/src/map/index.ts +4 -1
- package/src/map/osOpenNamesSearch.ts +112 -57
- package/src/map/useKeyboardDrawing.ts +2 -2
- package/src/map/utils.ts +2 -1
- package/src/test/renderers.tsx +9 -20
- package/src/map/geocoder.ts +0 -61
- package/src/ol-geocoder.d.ts +0 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { InfoBox, POSITION_TOP_LEFT, POSITION_BOTTOM_RIGHT } from './InfoBox';
|
|
2
|
+
import { render, screen, userEvent, waitFor } from '../../test/renderers';
|
|
3
|
+
|
|
4
|
+
const TEST_CONTENT = 'Test info content';
|
|
5
|
+
const TEST_TITLE = 'Test Title';
|
|
6
|
+
const ARIA_EXPANDED = 'aria-expanded';
|
|
7
|
+
|
|
8
|
+
// Mock focus-trap-react to simplify testing
|
|
9
|
+
vi.mock('focus-trap-react', () => ({
|
|
10
|
+
FocusTrap: ({ children }: { children: React.ReactNode }) => (
|
|
11
|
+
<div data-testid="focus-trap">{children}</div>
|
|
12
|
+
),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe('InfoBox', () => {
|
|
16
|
+
describe('rendering', () => {
|
|
17
|
+
it('should render the trigger button with info icon', () => {
|
|
18
|
+
render(
|
|
19
|
+
<InfoBox>
|
|
20
|
+
<p>{TEST_CONTENT}</p>
|
|
21
|
+
</InfoBox>,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
25
|
+
|
|
26
|
+
expect(button).toBeInTheDocument();
|
|
27
|
+
expect(button.querySelector('svg')).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should not render content when closed', () => {
|
|
31
|
+
render(
|
|
32
|
+
<InfoBox>
|
|
33
|
+
<p>{TEST_CONTENT}</p>
|
|
34
|
+
</InfoBox>,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should render content when defaultOpen is true', () => {
|
|
41
|
+
render(
|
|
42
|
+
<InfoBox defaultOpen>
|
|
43
|
+
<p>{TEST_CONTENT}</p>
|
|
44
|
+
</InfoBox>,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should render title when provided', () => {
|
|
51
|
+
render(
|
|
52
|
+
<InfoBox title={TEST_TITLE} defaultOpen>
|
|
53
|
+
<p>{TEST_CONTENT}</p>
|
|
54
|
+
</InfoBox>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(screen.getByText(TEST_TITLE)).toBeInTheDocument();
|
|
58
|
+
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(TEST_TITLE);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should not render title element when not provided', () => {
|
|
62
|
+
render(
|
|
63
|
+
<InfoBox defaultOpen>
|
|
64
|
+
<p>{TEST_CONTENT}</p>
|
|
65
|
+
</InfoBox>,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('interactions', () => {
|
|
73
|
+
it('should open content when trigger is clicked', async () => {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
|
|
76
|
+
render(
|
|
77
|
+
<InfoBox>
|
|
78
|
+
<p>{TEST_CONTENT}</p>
|
|
79
|
+
</InfoBox>,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
83
|
+
|
|
84
|
+
expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
|
|
85
|
+
|
|
86
|
+
await user.click(button);
|
|
87
|
+
|
|
88
|
+
expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should close content when trigger is clicked while open', async () => {
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<InfoBox defaultOpen>
|
|
96
|
+
<p>{TEST_CONTENT}</p>
|
|
97
|
+
</InfoBox>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
|
|
101
|
+
|
|
102
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
103
|
+
|
|
104
|
+
await user.click(button);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Note: Escape key closing is handled by FocusTrap, which is mocked in tests.
|
|
112
|
+
// The real behavior is tested through FocusTrap's own test suite.
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('accessibility', () => {
|
|
116
|
+
it('should have aria-expanded attribute that toggles correctly', async () => {
|
|
117
|
+
const user = userEvent.setup();
|
|
118
|
+
|
|
119
|
+
render(
|
|
120
|
+
<InfoBox>
|
|
121
|
+
<p>{TEST_CONTENT}</p>
|
|
122
|
+
</InfoBox>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
126
|
+
|
|
127
|
+
expect(button).toHaveAttribute(ARIA_EXPANDED, 'false');
|
|
128
|
+
|
|
129
|
+
await user.click(button);
|
|
130
|
+
|
|
131
|
+
expect(button).toHaveAttribute(ARIA_EXPANDED, 'true');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should have aria-controls linking trigger to content', async () => {
|
|
135
|
+
const user = userEvent.setup();
|
|
136
|
+
|
|
137
|
+
render(
|
|
138
|
+
<InfoBox>
|
|
139
|
+
<p>{TEST_CONTENT}</p>
|
|
140
|
+
</InfoBox>,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
144
|
+
const controlsId = button.getAttribute('aria-controls');
|
|
145
|
+
|
|
146
|
+
expect(controlsId).toBeTruthy();
|
|
147
|
+
|
|
148
|
+
await user.click(button);
|
|
149
|
+
|
|
150
|
+
const dialog = screen.getByRole('dialog');
|
|
151
|
+
|
|
152
|
+
expect(dialog).toHaveAttribute('id', controlsId);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should have aria-haspopup="dialog" on trigger', () => {
|
|
156
|
+
render(
|
|
157
|
+
<InfoBox>
|
|
158
|
+
<p>{TEST_CONTENT}</p>
|
|
159
|
+
</InfoBox>,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
163
|
+
|
|
164
|
+
expect(button).toHaveAttribute('aria-haspopup', 'dialog');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should render as a dialog element', async () => {
|
|
168
|
+
const user = userEvent.setup();
|
|
169
|
+
|
|
170
|
+
render(
|
|
171
|
+
<InfoBox>
|
|
172
|
+
<p>{TEST_CONTENT}</p>
|
|
173
|
+
</InfoBox>,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
177
|
+
|
|
178
|
+
const dialog = screen.getByRole('dialog');
|
|
179
|
+
|
|
180
|
+
// Native <dialog> element provides implicit dialog semantics
|
|
181
|
+
expect(dialog.tagName).toBe('DIALOG');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should have aria-labelledby when title is provided', async () => {
|
|
185
|
+
const user = userEvent.setup();
|
|
186
|
+
|
|
187
|
+
render(
|
|
188
|
+
<InfoBox title={TEST_TITLE}>
|
|
189
|
+
<p>{TEST_CONTENT}</p>
|
|
190
|
+
</InfoBox>,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
194
|
+
|
|
195
|
+
const dialog = screen.getByRole('dialog');
|
|
196
|
+
const titleId = dialog.getAttribute('aria-labelledby');
|
|
197
|
+
|
|
198
|
+
expect(titleId).toBeTruthy();
|
|
199
|
+
|
|
200
|
+
const title = screen.getByRole('heading', { level: 2 });
|
|
201
|
+
|
|
202
|
+
expect(title).toHaveAttribute('id', titleId);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should have aria-label when title is not provided', async () => {
|
|
206
|
+
const user = userEvent.setup();
|
|
207
|
+
|
|
208
|
+
render(
|
|
209
|
+
<InfoBox>
|
|
210
|
+
<p>{TEST_CONTENT}</p>
|
|
211
|
+
</InfoBox>,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
215
|
+
|
|
216
|
+
const dialog = screen.getByRole('dialog');
|
|
217
|
+
|
|
218
|
+
expect(dialog).toHaveAttribute('aria-label', 'Information');
|
|
219
|
+
expect(dialog).not.toHaveAttribute('aria-labelledby');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should use custom triggerLabel when provided', () => {
|
|
223
|
+
render(
|
|
224
|
+
<InfoBox triggerLabel="Learn more about this feature">
|
|
225
|
+
<p>{TEST_CONTENT}</p>
|
|
226
|
+
</InfoBox>,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(
|
|
230
|
+
screen.getByRole('button', { name: /learn more about this feature/i }),
|
|
231
|
+
).toBeInTheDocument();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('callbacks', () => {
|
|
236
|
+
it('should call onOpenChange when opening', async () => {
|
|
237
|
+
const user = userEvent.setup();
|
|
238
|
+
const onOpenChange = vi.fn();
|
|
239
|
+
|
|
240
|
+
render(
|
|
241
|
+
<InfoBox onOpenChange={onOpenChange}>
|
|
242
|
+
<p>{TEST_CONTENT}</p>
|
|
243
|
+
</InfoBox>,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
247
|
+
|
|
248
|
+
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should call onOpenChange when closing', async () => {
|
|
252
|
+
const user = userEvent.setup();
|
|
253
|
+
const onOpenChange = vi.fn();
|
|
254
|
+
|
|
255
|
+
render(
|
|
256
|
+
<InfoBox defaultOpen onOpenChange={onOpenChange}>
|
|
257
|
+
<p>{TEST_CONTENT}</p>
|
|
258
|
+
</InfoBox>,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
262
|
+
|
|
263
|
+
await waitFor(() => {
|
|
264
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('positioning', () => {
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
// Mock window dimensions
|
|
272
|
+
Object.defineProperty(globalThis, 'innerWidth', { value: 1000, writable: true });
|
|
273
|
+
Object.defineProperty(globalThis, 'innerHeight', { value: 800, writable: true });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should use forced position when position prop is provided', async () => {
|
|
277
|
+
const user = userEvent.setup();
|
|
278
|
+
|
|
279
|
+
render(
|
|
280
|
+
<InfoBox position={POSITION_TOP_LEFT}>
|
|
281
|
+
<p>{TEST_CONTENT}</p>
|
|
282
|
+
</InfoBox>,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
286
|
+
|
|
287
|
+
const dialog = screen.getByRole('dialog');
|
|
288
|
+
|
|
289
|
+
// top-left position applies both X and Y transforms
|
|
290
|
+
expect(dialog).toHaveClass('-translate-x-full', '-translate-y-full');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should apply correct transform for bottom-right position', async () => {
|
|
294
|
+
const user = userEvent.setup();
|
|
295
|
+
|
|
296
|
+
render(
|
|
297
|
+
<InfoBox position={POSITION_BOTTOM_RIGHT}>
|
|
298
|
+
<p>{TEST_CONTENT}</p>
|
|
299
|
+
</InfoBox>,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
303
|
+
|
|
304
|
+
const dialog = screen.getByRole('dialog');
|
|
305
|
+
|
|
306
|
+
// bottom-right has no transforms
|
|
307
|
+
expect(dialog).not.toHaveClass('-translate-x-full');
|
|
308
|
+
expect(dialog).not.toHaveClass('-translate-y-full');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('styling', () => {
|
|
313
|
+
it('should apply maxWidth style', async () => {
|
|
314
|
+
const user = userEvent.setup();
|
|
315
|
+
|
|
316
|
+
render(
|
|
317
|
+
<InfoBox maxWidth="400px">
|
|
318
|
+
<p>{TEST_CONTENT}</p>
|
|
319
|
+
</InfoBox>,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
323
|
+
|
|
324
|
+
const dialog = screen.getByRole('dialog');
|
|
325
|
+
|
|
326
|
+
expect(dialog).toHaveStyle({ maxWidth: 'min(400px, calc(100vw - 32px))' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should merge custom className', () => {
|
|
330
|
+
render(
|
|
331
|
+
<InfoBox className="custom-class">
|
|
332
|
+
<p>{TEST_CONTENT}</p>
|
|
333
|
+
</InfoBox>,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const container = screen.getByRole('button', { name: /show information/i }).parentElement;
|
|
337
|
+
|
|
338
|
+
expect(container).toHaveClass('custom-class');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should have correct trigger button styling', () => {
|
|
342
|
+
render(
|
|
343
|
+
<InfoBox>
|
|
344
|
+
<p>{TEST_CONTENT}</p>
|
|
345
|
+
</InfoBox>,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
349
|
+
|
|
350
|
+
expect(button).toHaveClass('rounded-full', 'bg-transparent');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe('complex content', () => {
|
|
355
|
+
it('should render interactive content correctly', async () => {
|
|
356
|
+
const user = userEvent.setup();
|
|
357
|
+
const onButtonClick = vi.fn();
|
|
358
|
+
|
|
359
|
+
render(
|
|
360
|
+
<InfoBox defaultOpen>
|
|
361
|
+
<div>
|
|
362
|
+
<p>Description text</p>
|
|
363
|
+
|
|
364
|
+
<input type="text" placeholder="Enter text" />
|
|
365
|
+
|
|
366
|
+
<button onClick={onButtonClick}>Action</button>
|
|
367
|
+
</div>
|
|
368
|
+
</InfoBox>,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(screen.getByText('Description text')).toBeInTheDocument();
|
|
372
|
+
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
|
|
373
|
+
|
|
374
|
+
const actionButton = screen.getByRole('button', { name: 'Action' });
|
|
375
|
+
|
|
376
|
+
expect(actionButton).toBeInTheDocument();
|
|
377
|
+
|
|
378
|
+
await user.click(actionButton);
|
|
379
|
+
expect(onButtonClick).toHaveBeenCalled();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useRef, useId, type RefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
import { FocusTrap } from 'focus-trap-react';
|
|
6
|
+
import { createPortal } from 'react-dom';
|
|
7
|
+
import { FaInfoCircle } from 'react-icons/fa';
|
|
8
|
+
|
|
9
|
+
import { useInfoBoxPosition, useInfoBoxState, usePortalMount } from './hooks';
|
|
10
|
+
import type { Position } from './types';
|
|
11
|
+
import { getTransformClasses, getFocusTrapConfig } from './utils';
|
|
12
|
+
import type { ExtendProps } from '../../types';
|
|
13
|
+
import { cn } from '../../utils';
|
|
14
|
+
|
|
15
|
+
// Re-export position constants for consumer convenience
|
|
16
|
+
export {
|
|
17
|
+
POSITION_TOP_LEFT,
|
|
18
|
+
POSITION_TOP_RIGHT,
|
|
19
|
+
POSITION_BOTTOM_LEFT,
|
|
20
|
+
POSITION_BOTTOM_RIGHT,
|
|
21
|
+
} from './types';
|
|
22
|
+
export type { Position } from './types';
|
|
23
|
+
|
|
24
|
+
type Props = {
|
|
25
|
+
/** Optional title displayed at the top of the info box content */
|
|
26
|
+
title?: string;
|
|
27
|
+
/** Content to display inside the info box */
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
/** Whether the info box starts open (default: false) */
|
|
30
|
+
defaultOpen?: boolean;
|
|
31
|
+
/** Callback when the info box opens or closes */
|
|
32
|
+
onOpenChange?: (isOpen: boolean) => void;
|
|
33
|
+
/** Maximum width of the info box (default: '320px') */
|
|
34
|
+
maxWidth?: string;
|
|
35
|
+
/** Custom aria-label for the trigger button (default: 'Show information') */
|
|
36
|
+
triggerLabel?: string;
|
|
37
|
+
/** Force a specific position instead of auto-calculating */
|
|
38
|
+
position?: Position;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type InfoBoxProps = ExtendProps<'div', Props>;
|
|
42
|
+
|
|
43
|
+
export const InfoBox = ({
|
|
44
|
+
title,
|
|
45
|
+
children,
|
|
46
|
+
defaultOpen = false,
|
|
47
|
+
onOpenChange,
|
|
48
|
+
maxWidth = '320px',
|
|
49
|
+
triggerLabel = 'Show information',
|
|
50
|
+
position: forcedPosition,
|
|
51
|
+
className,
|
|
52
|
+
...props
|
|
53
|
+
}: InfoBoxProps) => {
|
|
54
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
55
|
+
const contentRef = useRef<HTMLDialogElement>(null);
|
|
56
|
+
|
|
57
|
+
const triggerId = useId();
|
|
58
|
+
const contentId = useId();
|
|
59
|
+
const titleId = useId();
|
|
60
|
+
|
|
61
|
+
// Custom hooks for separation of concerns
|
|
62
|
+
const isMounted = usePortalMount();
|
|
63
|
+
const {
|
|
64
|
+
isOpen,
|
|
65
|
+
isTrapActive,
|
|
66
|
+
setIsTrapActive,
|
|
67
|
+
isOpenRef,
|
|
68
|
+
deactivatedByClick,
|
|
69
|
+
handleClose,
|
|
70
|
+
toggleOpen,
|
|
71
|
+
} = useInfoBoxState({ defaultOpen, onOpenChange });
|
|
72
|
+
const { calculatedPosition, contentPosition } = useInfoBoxPosition({
|
|
73
|
+
isOpen,
|
|
74
|
+
triggerRef: triggerRef as RefObject<HTMLElement | null>,
|
|
75
|
+
forcedPosition,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const focusTrapConfig = getFocusTrapConfig({
|
|
79
|
+
isOpenRef,
|
|
80
|
+
deactivatedByClick,
|
|
81
|
+
triggerRef: triggerRef as RefObject<HTMLElement | null>,
|
|
82
|
+
contentRef: contentRef as RefObject<HTMLElement | null>,
|
|
83
|
+
handleClose,
|
|
84
|
+
setIsTrapActive,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const triggerClasses = cn(
|
|
88
|
+
// Base styles - button structure only
|
|
89
|
+
'inline-flex items-center justify-center',
|
|
90
|
+
'w-6 h-6 rounded-full',
|
|
91
|
+
'bg-transparent border-none',
|
|
92
|
+
// Focus outline only
|
|
93
|
+
'focus:outline focus:outline-[3px] focus:outline-focus',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const iconClasses = cn(
|
|
97
|
+
// Icon size
|
|
98
|
+
'w-5 h-5',
|
|
99
|
+
// Icon color - yellow when open, black when closed
|
|
100
|
+
isOpen ? 'text-focus' : 'text-black',
|
|
101
|
+
// Hover state - yellow
|
|
102
|
+
'hover:text-focus',
|
|
103
|
+
// Focus state - yellow
|
|
104
|
+
'focus:text-focus',
|
|
105
|
+
// Transition
|
|
106
|
+
'transition-colors duration-150',
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const contentClasses = cn(
|
|
110
|
+
// Position
|
|
111
|
+
'fixed',
|
|
112
|
+
// Base styles
|
|
113
|
+
'bg-white rounded-lg shadow-lg border border-gray-200',
|
|
114
|
+
'p-4',
|
|
115
|
+
// Width constraints
|
|
116
|
+
'min-w-[280px]',
|
|
117
|
+
// Animation
|
|
118
|
+
'transition-all duration-200 ease-out',
|
|
119
|
+
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none',
|
|
120
|
+
// Transform based on position
|
|
121
|
+
getTransformClasses(calculatedPosition),
|
|
122
|
+
// Z-index
|
|
123
|
+
'z-50',
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className={cn('relative inline-flex', className)} {...props}>
|
|
128
|
+
<button
|
|
129
|
+
ref={triggerRef}
|
|
130
|
+
id={triggerId}
|
|
131
|
+
type="button"
|
|
132
|
+
aria-expanded={isOpen}
|
|
133
|
+
aria-controls={contentId}
|
|
134
|
+
aria-haspopup="dialog"
|
|
135
|
+
aria-label={triggerLabel}
|
|
136
|
+
onClick={toggleOpen}
|
|
137
|
+
className={triggerClasses}
|
|
138
|
+
>
|
|
139
|
+
<FaInfoCircle className={iconClasses} aria-hidden="true" />
|
|
140
|
+
</button>
|
|
141
|
+
|
|
142
|
+
{isMounted &&
|
|
143
|
+
isOpen &&
|
|
144
|
+
createPortal(
|
|
145
|
+
<FocusTrap active={isTrapActive} focusTrapOptions={focusTrapConfig}>
|
|
146
|
+
<dialog
|
|
147
|
+
ref={contentRef}
|
|
148
|
+
id={contentId}
|
|
149
|
+
open
|
|
150
|
+
aria-labelledby={title ? titleId : undefined}
|
|
151
|
+
aria-label={title ? undefined : 'Information'}
|
|
152
|
+
tabIndex={-1}
|
|
153
|
+
style={{
|
|
154
|
+
top: contentPosition.top,
|
|
155
|
+
left: contentPosition.left,
|
|
156
|
+
maxWidth: `min(${maxWidth}, calc(100vw - 32px))`,
|
|
157
|
+
}}
|
|
158
|
+
className={contentClasses}
|
|
159
|
+
>
|
|
160
|
+
{title ? (
|
|
161
|
+
<h2 id={titleId} className="text-sm font-semibold text-gray-900 mb-2">
|
|
162
|
+
{title}
|
|
163
|
+
</h2>
|
|
164
|
+
) : null}
|
|
165
|
+
<div
|
|
166
|
+
className="text-sm text-gray-700 max-h-[min(24rem,calc(100vh-200px))]
|
|
167
|
+
overflow-y-auto"
|
|
168
|
+
>
|
|
169
|
+
{children}
|
|
170
|
+
</div>
|
|
171
|
+
</dialog>
|
|
172
|
+
</FocusTrap>,
|
|
173
|
+
document.body,
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
};
|