@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,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
+ };
@@ -0,0 +1,3 @@
1
+ export { useInfoBoxPosition } from './useInfoBoxPosition';
2
+ export { useInfoBoxState } from './useInfoBoxState';
3
+ export { usePortalMount } from './usePortalMount';