@tpzdsp/next-toolkit 1.12.3 → 1.12.5
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 +10 -1
- package/src/components/InfoBox/InfoBox.stories.tsx +460 -0
- package/src/components/InfoBox/InfoBox.test.tsx +330 -0
- package/src/components/InfoBox/InfoBox.tsx +172 -0
- package/src/components/InfoBox/types.ts +6 -0
- package/src/components/index.ts +1 -0
- package/src/map/MapComponent.tsx +13 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpzdsp/next-toolkit",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.5",
|
|
4
4
|
"description": "A reusable React component library for Next.js applications",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">= 24.12.0",
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
"import": "./src/components/select/index.ts",
|
|
58
58
|
"require": "./src/components/select/index.ts"
|
|
59
59
|
},
|
|
60
|
+
"./components/info-box": {
|
|
61
|
+
"types": "./src/components/InfoBox/InfoBox.tsx",
|
|
62
|
+
"import": "./src/components/InfoBox/InfoBox.tsx",
|
|
63
|
+
"require": "./src/components/InfoBox/InfoBox.tsx"
|
|
64
|
+
},
|
|
60
65
|
"./http": {
|
|
61
66
|
"types": "./src/http/index.ts",
|
|
62
67
|
"import": "./src/http/index.ts",
|
|
@@ -119,6 +124,7 @@
|
|
|
119
124
|
},
|
|
120
125
|
"devDependencies": {
|
|
121
126
|
"@better-fetch/fetch": "^1.1.21",
|
|
127
|
+
"@floating-ui/react": "^0.27.17",
|
|
122
128
|
"@eslint/js": "^9.30.1",
|
|
123
129
|
"@semantic-release/changelog": "^6.0.3",
|
|
124
130
|
"@semantic-release/git": "^10.0.1",
|
|
@@ -229,6 +235,9 @@
|
|
|
229
235
|
"@better-fetch/fetch": {
|
|
230
236
|
"optional": true
|
|
231
237
|
},
|
|
238
|
+
"@floating-ui/react": {
|
|
239
|
+
"optional": true
|
|
240
|
+
},
|
|
232
241
|
"@turf/turf": {
|
|
233
242
|
"optional": true
|
|
234
243
|
},
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
|
|
3
|
+
import { InfoBox } from './InfoBox';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/InfoBox',
|
|
7
|
+
component: InfoBox,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'centered',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'An accessible info box component that displays contextual information in a popover triggered by an info icon button. Uses Floating UI for intelligent auto-positioning that adapts to viewport constraints.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
argTypes: {
|
|
19
|
+
title: {
|
|
20
|
+
control: 'text',
|
|
21
|
+
description: 'Optional title displayed at the top of the info box content',
|
|
22
|
+
},
|
|
23
|
+
defaultOpen: {
|
|
24
|
+
control: 'boolean',
|
|
25
|
+
description: 'Whether the info box starts open',
|
|
26
|
+
defaultValue: false,
|
|
27
|
+
},
|
|
28
|
+
maxWidth: {
|
|
29
|
+
control: 'text',
|
|
30
|
+
description: 'Maximum width of the content box (default: 320px)',
|
|
31
|
+
},
|
|
32
|
+
triggerLabel: {
|
|
33
|
+
control: 'text',
|
|
34
|
+
description: 'Accessible label for the trigger button',
|
|
35
|
+
},
|
|
36
|
+
placement: {
|
|
37
|
+
control: 'select',
|
|
38
|
+
options: [
|
|
39
|
+
'top',
|
|
40
|
+
'top-start',
|
|
41
|
+
'top-end',
|
|
42
|
+
'bottom',
|
|
43
|
+
'bottom-start',
|
|
44
|
+
'bottom-end',
|
|
45
|
+
'left',
|
|
46
|
+
'left-start',
|
|
47
|
+
'left-end',
|
|
48
|
+
'right',
|
|
49
|
+
'right-start',
|
|
50
|
+
'right-end',
|
|
51
|
+
],
|
|
52
|
+
description:
|
|
53
|
+
'Preferred placement (Floating UI will auto-adjust if there is not enough space)',
|
|
54
|
+
},
|
|
55
|
+
children: {
|
|
56
|
+
control: false,
|
|
57
|
+
description: 'Content to display inside the info box',
|
|
58
|
+
},
|
|
59
|
+
onOpenChange: {
|
|
60
|
+
action: 'onOpenChange',
|
|
61
|
+
description: 'Callback when the info box opens or closes',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
} satisfies Meta<typeof InfoBox>;
|
|
65
|
+
|
|
66
|
+
export default meta;
|
|
67
|
+
type Story = StoryObj<typeof meta>;
|
|
68
|
+
|
|
69
|
+
export const Default: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
children: <p>This is helpful information about this feature.</p>,
|
|
72
|
+
},
|
|
73
|
+
parameters: {
|
|
74
|
+
docs: {
|
|
75
|
+
description: {
|
|
76
|
+
story: 'Basic info box with simple text content. Click the info icon to open.',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const WithTitle: Story = {
|
|
83
|
+
args: {
|
|
84
|
+
title: 'Important Information',
|
|
85
|
+
children: (
|
|
86
|
+
<div className="space-y-2">
|
|
87
|
+
<p>Here are some key points to remember:</p>
|
|
88
|
+
|
|
89
|
+
<ul className="list-disc list-inside text-sm">
|
|
90
|
+
<li>Point one</li>
|
|
91
|
+
|
|
92
|
+
<li>Point two</li>
|
|
93
|
+
|
|
94
|
+
<li>Point three</li>
|
|
95
|
+
</ul>
|
|
96
|
+
</div>
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
parameters: {
|
|
100
|
+
docs: {
|
|
101
|
+
description: {
|
|
102
|
+
story: 'Info box with a title header for more structured content.',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const DefaultOpen: Story = {
|
|
109
|
+
args: {
|
|
110
|
+
title: 'Getting Started',
|
|
111
|
+
defaultOpen: true,
|
|
112
|
+
children: <p>This info box starts open by default.</p>,
|
|
113
|
+
},
|
|
114
|
+
parameters: {
|
|
115
|
+
docs: {
|
|
116
|
+
description: {
|
|
117
|
+
story: 'Info box that starts in the open state.',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const LongContent: Story = {
|
|
124
|
+
args: {
|
|
125
|
+
title: 'Detailed Information',
|
|
126
|
+
maxWidth: '400px',
|
|
127
|
+
children: (
|
|
128
|
+
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
129
|
+
<p>
|
|
130
|
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt
|
|
131
|
+
ut labore et dolore magna aliqua.
|
|
132
|
+
</p>
|
|
133
|
+
|
|
134
|
+
<p>
|
|
135
|
+
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
|
136
|
+
commodo consequat.
|
|
137
|
+
</p>
|
|
138
|
+
|
|
139
|
+
<p>
|
|
140
|
+
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
|
|
141
|
+
nulla pariatur.
|
|
142
|
+
</p>
|
|
143
|
+
|
|
144
|
+
<p>
|
|
145
|
+
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
|
|
146
|
+
anim id est laborum.
|
|
147
|
+
</p>
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
parameters: {
|
|
152
|
+
docs: {
|
|
153
|
+
description: {
|
|
154
|
+
story: 'Info box with longer scrollable content and custom max width.',
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const AllPositions: Story = {
|
|
161
|
+
args: {
|
|
162
|
+
children: null,
|
|
163
|
+
},
|
|
164
|
+
parameters: {
|
|
165
|
+
layout: 'fullscreen',
|
|
166
|
+
docs: {
|
|
167
|
+
description: {
|
|
168
|
+
story:
|
|
169
|
+
'Demonstrates various placement options using Floating UI. The content will automatically adjust if there is not enough space.',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
render: () => (
|
|
174
|
+
<div className="p-8 grid grid-cols-2 gap-8 h-screen">
|
|
175
|
+
<div className="flex items-start justify-start">
|
|
176
|
+
<div className="flex items-center gap-2">
|
|
177
|
+
<span className="text-sm text-gray-600">bottom-start:</span>
|
|
178
|
+
|
|
179
|
+
<InfoBox title="Position Demo" placement="bottom-start">
|
|
180
|
+
<p>Prefers bottom-start placement.</p>
|
|
181
|
+
</InfoBox>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div className="flex items-start justify-end">
|
|
186
|
+
<div className="flex items-center gap-2">
|
|
187
|
+
<span className="text-sm text-gray-600">bottom-end:</span>
|
|
188
|
+
|
|
189
|
+
<InfoBox title="Position Demo" placement="bottom-end">
|
|
190
|
+
<p>Prefers bottom-end placement.</p>
|
|
191
|
+
</InfoBox>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className="flex items-end justify-start">
|
|
196
|
+
<div className="flex items-center gap-2">
|
|
197
|
+
<span className="text-sm text-gray-600">top-start:</span>
|
|
198
|
+
|
|
199
|
+
<InfoBox title="Position Demo" placement="top-start">
|
|
200
|
+
<p>Prefers top-start placement.</p>
|
|
201
|
+
</InfoBox>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div className="flex items-end justify-end">
|
|
206
|
+
<div className="flex items-center gap-2">
|
|
207
|
+
<span className="text-sm text-gray-600">top-end:</span>
|
|
208
|
+
|
|
209
|
+
<InfoBox title="Position Demo" placement="top-end">
|
|
210
|
+
<p>Prefers top-end placement.</p>
|
|
211
|
+
</InfoBox>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export const InCorners: Story = {
|
|
219
|
+
args: {
|
|
220
|
+
children: null,
|
|
221
|
+
},
|
|
222
|
+
parameters: {
|
|
223
|
+
layout: 'fullscreen',
|
|
224
|
+
docs: {
|
|
225
|
+
description: {
|
|
226
|
+
story:
|
|
227
|
+
'Demonstrates auto-positioning when the info box is placed in different corners of the viewport. The info box automatically positions itself to stay visible.',
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
render: () => (
|
|
232
|
+
<div className="relative h-screen w-full">
|
|
233
|
+
<div className="absolute top-4 left-4 flex items-center gap-2">
|
|
234
|
+
<InfoBox>
|
|
235
|
+
<p>Auto-positioned from top-left corner. Should open to bottom-right.</p>
|
|
236
|
+
</InfoBox>
|
|
237
|
+
|
|
238
|
+
<span className="text-xs text-gray-500">Top-left corner</span>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="absolute top-4 right-4 flex items-center gap-2">
|
|
242
|
+
<span className="text-xs text-gray-500">Top-right corner</span>
|
|
243
|
+
|
|
244
|
+
<InfoBox>
|
|
245
|
+
<p>Auto-positioned from top-right corner. Should open to bottom-left.</p>
|
|
246
|
+
</InfoBox>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="absolute bottom-4 left-4 flex items-center gap-2">
|
|
250
|
+
<InfoBox>
|
|
251
|
+
<p>Auto-positioned from bottom-left corner. Should open to top-right.</p>
|
|
252
|
+
</InfoBox>
|
|
253
|
+
|
|
254
|
+
<span className="text-xs text-gray-500">Bottom-left corner</span>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div className="absolute bottom-4 right-4 flex items-center gap-2">
|
|
258
|
+
<span className="text-xs text-gray-500">Bottom-right corner</span>
|
|
259
|
+
|
|
260
|
+
<InfoBox>
|
|
261
|
+
<p>Auto-positioned from bottom-right corner. Should open to top-left.</p>
|
|
262
|
+
</InfoBox>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
|
|
266
|
+
<p className="text-gray-400 text-sm">
|
|
267
|
+
Click the info icons in each corner to see auto-positioning
|
|
268
|
+
</p>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
),
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export const WithInteractiveContent: Story = {
|
|
275
|
+
args: {
|
|
276
|
+
title: 'Take Action',
|
|
277
|
+
children: (
|
|
278
|
+
<div className="space-y-3">
|
|
279
|
+
<p className="text-sm">You can interact with elements inside:</p>
|
|
280
|
+
|
|
281
|
+
<input
|
|
282
|
+
type="text"
|
|
283
|
+
placeholder="Type something..."
|
|
284
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none
|
|
285
|
+
focus:ring-2 focus:ring-blue-500"
|
|
286
|
+
/>
|
|
287
|
+
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
className="w-full px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700
|
|
291
|
+
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
292
|
+
>
|
|
293
|
+
Submit
|
|
294
|
+
</button>
|
|
295
|
+
</div>
|
|
296
|
+
),
|
|
297
|
+
},
|
|
298
|
+
parameters: {
|
|
299
|
+
docs: {
|
|
300
|
+
description: {
|
|
301
|
+
story:
|
|
302
|
+
'Info box with interactive content (form elements). Focus is trapped within the info box when open.',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export const InlineWithText: Story = {
|
|
309
|
+
args: {
|
|
310
|
+
children: null,
|
|
311
|
+
},
|
|
312
|
+
parameters: {
|
|
313
|
+
docs: {
|
|
314
|
+
description: {
|
|
315
|
+
story: 'Info box used inline with paragraph text to provide contextual information.',
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
render: () => (
|
|
320
|
+
<div className="max-w-md p-4">
|
|
321
|
+
<p className="text-sm leading-relaxed">
|
|
322
|
+
This is a paragraph with an inline info box{' '}
|
|
323
|
+
<span className="inline-flex align-middle mx-1">
|
|
324
|
+
<InfoBox triggerLabel="Learn more about this term">
|
|
325
|
+
<p>Additional context about the preceding text that helps explain the concept.</p>
|
|
326
|
+
</InfoBox>
|
|
327
|
+
</span>{' '}
|
|
328
|
+
that provides more context about a specific term or concept in the text.
|
|
329
|
+
</p>
|
|
330
|
+
</div>
|
|
331
|
+
),
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export const CustomTriggerLabel: Story = {
|
|
335
|
+
args: {
|
|
336
|
+
triggerLabel: 'Learn more about data privacy',
|
|
337
|
+
title: 'Data Privacy',
|
|
338
|
+
children: (
|
|
339
|
+
<div className="space-y-2 text-sm">
|
|
340
|
+
<p>Your data is handled securely and in accordance with our privacy policy.</p>
|
|
341
|
+
|
|
342
|
+
<a href="https://example.com" className="text-blue-600 hover:underline">
|
|
343
|
+
Read our full privacy policy
|
|
344
|
+
</a>
|
|
345
|
+
</div>
|
|
346
|
+
),
|
|
347
|
+
},
|
|
348
|
+
parameters: {
|
|
349
|
+
docs: {
|
|
350
|
+
description: {
|
|
351
|
+
story:
|
|
352
|
+
'Info box with a custom trigger label for better accessibility context. Screen readers will announce the custom label.',
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
export const MultipleInfoBoxes: Story = {
|
|
359
|
+
args: {
|
|
360
|
+
children: null,
|
|
361
|
+
},
|
|
362
|
+
parameters: {
|
|
363
|
+
docs: {
|
|
364
|
+
description: {
|
|
365
|
+
story: 'Multiple info boxes can be used independently on the same page.',
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
render: () => (
|
|
370
|
+
<div className="space-y-4 p-4">
|
|
371
|
+
<div className="flex items-center gap-2">
|
|
372
|
+
<span className="text-sm font-medium">Username</span>
|
|
373
|
+
|
|
374
|
+
<InfoBox triggerLabel="Username requirements">
|
|
375
|
+
<p className="text-sm">Username must be 3-20 characters and contain only letters.</p>
|
|
376
|
+
</InfoBox>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div className="flex items-center gap-2">
|
|
380
|
+
<span className="text-sm font-medium">Password</span>
|
|
381
|
+
|
|
382
|
+
<InfoBox triggerLabel="Password requirements">
|
|
383
|
+
<div className="text-sm space-y-1">
|
|
384
|
+
<p>Password must contain:</p>
|
|
385
|
+
|
|
386
|
+
<ul className="list-disc list-inside">
|
|
387
|
+
<li>At least 8 characters</li>
|
|
388
|
+
|
|
389
|
+
<li>One uppercase letter</li>
|
|
390
|
+
|
|
391
|
+
<li>One number</li>
|
|
392
|
+
</ul>
|
|
393
|
+
</div>
|
|
394
|
+
</InfoBox>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<div className="flex items-center gap-2">
|
|
398
|
+
<span className="text-sm font-medium">Email</span>
|
|
399
|
+
|
|
400
|
+
<InfoBox triggerLabel="Why we need your email">
|
|
401
|
+
<p className="text-sm">We use your email for account recovery and important updates.</p>
|
|
402
|
+
</InfoBox>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
),
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
export const Accessibility: Story = {
|
|
409
|
+
args: {
|
|
410
|
+
children: null,
|
|
411
|
+
},
|
|
412
|
+
parameters: {
|
|
413
|
+
docs: {
|
|
414
|
+
description: {
|
|
415
|
+
story: 'Demonstrates the accessibility features of the InfoBox component.',
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
render: () => (
|
|
420
|
+
<div className="space-y-6 max-w-md p-4">
|
|
421
|
+
<div className="bg-blue-50 border border-blue-200 rounded p-4">
|
|
422
|
+
<h3 className="font-bold text-blue-800 mb-2">Accessibility Features:</h3>
|
|
423
|
+
|
|
424
|
+
<ul className="list-disc list-inside space-y-1 text-blue-700 text-sm">
|
|
425
|
+
<li>Trigger button has aria-expanded and aria-haspopup</li>
|
|
426
|
+
|
|
427
|
+
<li>Content has role="dialog" and aria-modal="true"</li>
|
|
428
|
+
|
|
429
|
+
<li>Focus is trapped within the info box when open</li>
|
|
430
|
+
|
|
431
|
+
<li>Escape key closes the info box</li>
|
|
432
|
+
|
|
433
|
+
<li>Click outside closes the info box</li>
|
|
434
|
+
|
|
435
|
+
<li>Focus returns to trigger on close (via keyboard)</li>
|
|
436
|
+
|
|
437
|
+
<li>Title provides aria-labelledby relationship</li>
|
|
438
|
+
</ul>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<div className="flex items-center gap-2">
|
|
442
|
+
<span className="text-sm">Try this accessible info box:</span>
|
|
443
|
+
|
|
444
|
+
<InfoBox title="Accessible Info Box" triggerLabel="Show accessibility information">
|
|
445
|
+
<div className="text-sm space-y-2">
|
|
446
|
+
<p>This info box demonstrates proper accessibility:</p>
|
|
447
|
+
|
|
448
|
+
<ul className="list-disc list-inside">
|
|
449
|
+
<li>Use Tab to navigate between elements</li>
|
|
450
|
+
|
|
451
|
+
<li>Press Escape to close</li>
|
|
452
|
+
|
|
453
|
+
<li>Click outside to close</li>
|
|
454
|
+
</ul>
|
|
455
|
+
</div>
|
|
456
|
+
</InfoBox>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
),
|
|
460
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { InfoBox } 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
|
+
describe('InfoBox', () => {
|
|
9
|
+
describe('rendering', () => {
|
|
10
|
+
it('should render the trigger button with info icon', () => {
|
|
11
|
+
render(
|
|
12
|
+
<InfoBox>
|
|
13
|
+
<p>{TEST_CONTENT}</p>
|
|
14
|
+
</InfoBox>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
18
|
+
|
|
19
|
+
expect(button).toBeInTheDocument();
|
|
20
|
+
expect(button.querySelector('svg')).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should not render content when closed', () => {
|
|
24
|
+
render(
|
|
25
|
+
<InfoBox>
|
|
26
|
+
<p>{TEST_CONTENT}</p>
|
|
27
|
+
</InfoBox>,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should render content when defaultOpen is true', () => {
|
|
34
|
+
render(
|
|
35
|
+
<InfoBox defaultOpen>
|
|
36
|
+
<p>{TEST_CONTENT}</p>
|
|
37
|
+
</InfoBox>,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should render title when provided', () => {
|
|
44
|
+
render(
|
|
45
|
+
<InfoBox title={TEST_TITLE} defaultOpen>
|
|
46
|
+
<p>{TEST_CONTENT}</p>
|
|
47
|
+
</InfoBox>,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(screen.getByText(TEST_TITLE)).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(TEST_TITLE);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should not render title element when not provided', () => {
|
|
55
|
+
render(
|
|
56
|
+
<InfoBox defaultOpen>
|
|
57
|
+
<p>{TEST_CONTENT}</p>
|
|
58
|
+
</InfoBox>,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('interactions', () => {
|
|
66
|
+
it('should open content when trigger is clicked', async () => {
|
|
67
|
+
const user = userEvent.setup();
|
|
68
|
+
|
|
69
|
+
render(
|
|
70
|
+
<InfoBox>
|
|
71
|
+
<p>{TEST_CONTENT}</p>
|
|
72
|
+
</InfoBox>,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
76
|
+
|
|
77
|
+
expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
|
|
78
|
+
|
|
79
|
+
await user.click(button);
|
|
80
|
+
|
|
81
|
+
expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should close content when trigger is clicked while open', async () => {
|
|
85
|
+
const user = userEvent.setup();
|
|
86
|
+
|
|
87
|
+
render(
|
|
88
|
+
<InfoBox defaultOpen>
|
|
89
|
+
<p>{TEST_CONTENT}</p>
|
|
90
|
+
</InfoBox>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
|
|
94
|
+
|
|
95
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
96
|
+
|
|
97
|
+
await user.click(button);
|
|
98
|
+
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Note: Escape key closing is handled by FocusTrap, which is mocked in tests.
|
|
105
|
+
// The real behavior is tested through FocusTrap's own test suite.
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('accessibility', () => {
|
|
109
|
+
it('should have aria-expanded attribute that toggles correctly', async () => {
|
|
110
|
+
const user = userEvent.setup();
|
|
111
|
+
|
|
112
|
+
render(
|
|
113
|
+
<InfoBox>
|
|
114
|
+
<p>{TEST_CONTENT}</p>
|
|
115
|
+
</InfoBox>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
119
|
+
|
|
120
|
+
expect(button).toHaveAttribute(ARIA_EXPANDED, 'false');
|
|
121
|
+
|
|
122
|
+
await user.click(button);
|
|
123
|
+
|
|
124
|
+
expect(button).toHaveAttribute(ARIA_EXPANDED, 'true');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should have aria-haspopup="dialog" on trigger', () => {
|
|
128
|
+
render(
|
|
129
|
+
<InfoBox>
|
|
130
|
+
<p>{TEST_CONTENT}</p>
|
|
131
|
+
</InfoBox>,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
135
|
+
|
|
136
|
+
expect(button).toHaveAttribute('aria-haspopup', 'dialog');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should have dialog role', async () => {
|
|
140
|
+
const user = userEvent.setup();
|
|
141
|
+
|
|
142
|
+
render(
|
|
143
|
+
<InfoBox>
|
|
144
|
+
<p>{TEST_CONTENT}</p>
|
|
145
|
+
</InfoBox>,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
149
|
+
|
|
150
|
+
const dialog = screen.getByRole('dialog');
|
|
151
|
+
|
|
152
|
+
// Floating UI uses div with role="dialog"
|
|
153
|
+
expect(dialog).toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should have aria-labelledby when title is provided', async () => {
|
|
157
|
+
const user = userEvent.setup();
|
|
158
|
+
|
|
159
|
+
render(
|
|
160
|
+
<InfoBox title={TEST_TITLE}>
|
|
161
|
+
<p>{TEST_CONTENT}</p>
|
|
162
|
+
</InfoBox>,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
166
|
+
|
|
167
|
+
const dialog = screen.getByRole('dialog');
|
|
168
|
+
const titleId = dialog.getAttribute('aria-labelledby');
|
|
169
|
+
|
|
170
|
+
expect(titleId).toBeTruthy();
|
|
171
|
+
|
|
172
|
+
const title = screen.getByRole('heading', { level: 2 });
|
|
173
|
+
|
|
174
|
+
expect(title).toHaveAttribute('id', titleId);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should have aria-label when title is not provided', async () => {
|
|
178
|
+
const user = userEvent.setup();
|
|
179
|
+
|
|
180
|
+
render(
|
|
181
|
+
<InfoBox>
|
|
182
|
+
<p>{TEST_CONTENT}</p>
|
|
183
|
+
</InfoBox>,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
187
|
+
|
|
188
|
+
const dialog = screen.getByRole('dialog');
|
|
189
|
+
|
|
190
|
+
expect(dialog).toHaveAttribute('aria-label', 'Information');
|
|
191
|
+
expect(dialog).not.toHaveAttribute('aria-labelledby');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should use custom triggerLabel when provided', () => {
|
|
195
|
+
render(
|
|
196
|
+
<InfoBox triggerLabel="Learn more about this feature">
|
|
197
|
+
<p>{TEST_CONTENT}</p>
|
|
198
|
+
</InfoBox>,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(
|
|
202
|
+
screen.getByRole('button', { name: /learn more about this feature/i }),
|
|
203
|
+
).toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('callbacks', () => {
|
|
208
|
+
it('should call onOpenChange when opening', async () => {
|
|
209
|
+
const user = userEvent.setup();
|
|
210
|
+
const onOpenChange = vi.fn();
|
|
211
|
+
|
|
212
|
+
render(
|
|
213
|
+
<InfoBox onOpenChange={onOpenChange}>
|
|
214
|
+
<p>{TEST_CONTENT}</p>
|
|
215
|
+
</InfoBox>,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
219
|
+
|
|
220
|
+
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should call onOpenChange when closing', async () => {
|
|
224
|
+
const user = userEvent.setup();
|
|
225
|
+
const onOpenChange = vi.fn();
|
|
226
|
+
|
|
227
|
+
render(
|
|
228
|
+
<InfoBox defaultOpen onOpenChange={onOpenChange}>
|
|
229
|
+
<p>{TEST_CONTENT}</p>
|
|
230
|
+
</InfoBox>,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
234
|
+
|
|
235
|
+
await waitFor(() => {
|
|
236
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('positioning', () => {
|
|
242
|
+
it('should accept placement prop', async () => {
|
|
243
|
+
const user = userEvent.setup();
|
|
244
|
+
|
|
245
|
+
render(
|
|
246
|
+
<InfoBox placement="top-start">
|
|
247
|
+
<p>{TEST_CONTENT}</p>
|
|
248
|
+
</InfoBox>,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
252
|
+
|
|
253
|
+
const dialog = screen.getByRole('dialog');
|
|
254
|
+
|
|
255
|
+
// Floating UI handles positioning automatically
|
|
256
|
+
expect(dialog).toBeInTheDocument();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('styling', () => {
|
|
261
|
+
it('should apply maxWidth style', async () => {
|
|
262
|
+
const user = userEvent.setup();
|
|
263
|
+
|
|
264
|
+
render(
|
|
265
|
+
<InfoBox maxWidth="400px">
|
|
266
|
+
<p>{TEST_CONTENT}</p>
|
|
267
|
+
</InfoBox>,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await user.click(screen.getByRole('button', { name: /show information/i }));
|
|
271
|
+
|
|
272
|
+
const dialog = screen.getByRole('dialog');
|
|
273
|
+
|
|
274
|
+
expect(dialog).toHaveStyle({ maxWidth: 'min(400px, calc(100vw - 32px))' });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should merge custom className', () => {
|
|
278
|
+
render(
|
|
279
|
+
<InfoBox className="custom-class">
|
|
280
|
+
<p>{TEST_CONTENT}</p>
|
|
281
|
+
</InfoBox>,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const container = screen.getByRole('button', { name: /show information/i }).parentElement;
|
|
285
|
+
|
|
286
|
+
expect(container).toHaveClass('custom-class');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should have correct trigger button styling', () => {
|
|
290
|
+
render(
|
|
291
|
+
<InfoBox>
|
|
292
|
+
<p>{TEST_CONTENT}</p>
|
|
293
|
+
</InfoBox>,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const button = screen.getByRole('button', { name: /show information/i });
|
|
297
|
+
|
|
298
|
+
expect(button).toHaveClass('rounded-full', 'bg-transparent');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('complex content', () => {
|
|
303
|
+
it('should render interactive content correctly', async () => {
|
|
304
|
+
const user = userEvent.setup();
|
|
305
|
+
const onButtonClick = vi.fn();
|
|
306
|
+
|
|
307
|
+
render(
|
|
308
|
+
<InfoBox defaultOpen>
|
|
309
|
+
<div>
|
|
310
|
+
<p>Description text</p>
|
|
311
|
+
|
|
312
|
+
<input type="text" placeholder="Enter text" />
|
|
313
|
+
|
|
314
|
+
<button onClick={onButtonClick}>Action</button>
|
|
315
|
+
</div>
|
|
316
|
+
</InfoBox>,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
expect(screen.getByText('Description text')).toBeInTheDocument();
|
|
320
|
+
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
|
|
321
|
+
|
|
322
|
+
const actionButton = screen.getByRole('button', { name: 'Action' });
|
|
323
|
+
|
|
324
|
+
expect(actionButton).toBeInTheDocument();
|
|
325
|
+
|
|
326
|
+
await user.click(actionButton);
|
|
327
|
+
expect(onButtonClick).toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, type ComponentType, useRef, useId, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { FaInfoCircle } from 'react-icons/fa';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
useFloating,
|
|
9
|
+
autoUpdate,
|
|
10
|
+
offset,
|
|
11
|
+
flip,
|
|
12
|
+
shift,
|
|
13
|
+
arrow,
|
|
14
|
+
useClick,
|
|
15
|
+
useDismiss,
|
|
16
|
+
useRole,
|
|
17
|
+
useInteractions,
|
|
18
|
+
FloatingArrow,
|
|
19
|
+
FloatingFocusManager,
|
|
20
|
+
FloatingPortal,
|
|
21
|
+
type Placement,
|
|
22
|
+
} from '@floating-ui/react';
|
|
23
|
+
|
|
24
|
+
import type { ExtendProps } from '../../types';
|
|
25
|
+
import { cn } from '../../utils';
|
|
26
|
+
|
|
27
|
+
type Props = {
|
|
28
|
+
/** Optional title displayed at the top of the info box content */
|
|
29
|
+
title?: string;
|
|
30
|
+
/** Content to display inside the info box */
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
/** Whether the info box starts open (default: false) */
|
|
33
|
+
defaultOpen?: boolean;
|
|
34
|
+
/** Callback when the info box opens or closes */
|
|
35
|
+
onOpenChange?: (isOpen: boolean) => void;
|
|
36
|
+
/** Maximum width of the info box (default: '320px') */
|
|
37
|
+
maxWidth?: string;
|
|
38
|
+
/** Custom aria-label for the trigger button (default: 'Show information') */
|
|
39
|
+
triggerLabel?: string;
|
|
40
|
+
/** Preferred placement (Floating UI will auto-adjust if needed) */
|
|
41
|
+
placement?: Placement;
|
|
42
|
+
/** Custom icon component to replace the default FaInfoCircle */
|
|
43
|
+
icon?: ComponentType<{ className?: string }>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type InfoBoxProps = ExtendProps<'div', Props>;
|
|
47
|
+
|
|
48
|
+
export const InfoBox = ({
|
|
49
|
+
title,
|
|
50
|
+
children,
|
|
51
|
+
defaultOpen = false,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
maxWidth = '320px',
|
|
54
|
+
triggerLabel = 'Show information',
|
|
55
|
+
placement = 'bottom-start',
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
57
|
+
icon: Icon = FaInfoCircle,
|
|
58
|
+
className,
|
|
59
|
+
...props
|
|
60
|
+
}: InfoBoxProps) => {
|
|
61
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
62
|
+
const arrowRef = useRef(null);
|
|
63
|
+
|
|
64
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
65
|
+
open: isOpen,
|
|
66
|
+
onOpenChange: (open) => {
|
|
67
|
+
setIsOpen(open);
|
|
68
|
+
onOpenChange?.(open);
|
|
69
|
+
},
|
|
70
|
+
placement,
|
|
71
|
+
middleware: [
|
|
72
|
+
offset(12), // Distance from trigger
|
|
73
|
+
flip(), // Flip to opposite side if not enough space
|
|
74
|
+
shift({ padding: 8 }), // Shift along the axis to stay in viewport
|
|
75
|
+
arrow({ element: arrowRef }), // Arrow pointing to trigger
|
|
76
|
+
],
|
|
77
|
+
whileElementsMounted: autoUpdate, // Update position on scroll/resize
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const click = useClick(context);
|
|
81
|
+
const dismiss = useDismiss(context);
|
|
82
|
+
const role = useRole(context);
|
|
83
|
+
|
|
84
|
+
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
|
|
85
|
+
|
|
86
|
+
const triggerId = useId();
|
|
87
|
+
const contentId = useId();
|
|
88
|
+
const titleId = useId();
|
|
89
|
+
|
|
90
|
+
const triggerClasses = cn(
|
|
91
|
+
// Base styles - button structure only
|
|
92
|
+
'inline-flex items-center justify-center',
|
|
93
|
+
'w-6 h-6 rounded-full',
|
|
94
|
+
'bg-transparent border-none',
|
|
95
|
+
// Focus outline only
|
|
96
|
+
'focus:outline focus:outline-[3px] focus:outline-focus',
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const iconClasses = cn(
|
|
100
|
+
// Icon size
|
|
101
|
+
'w-5 h-5',
|
|
102
|
+
// Icon color - yellow when open, auto when closed
|
|
103
|
+
isOpen ? 'text-focus' : '',
|
|
104
|
+
// Hover state - yellow
|
|
105
|
+
'hover:text-focus',
|
|
106
|
+
// Focus state - yellow
|
|
107
|
+
'focus:text-focus',
|
|
108
|
+
// Transition
|
|
109
|
+
'transition-colors duration-150',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const contentClasses = cn(
|
|
113
|
+
// Base styles
|
|
114
|
+
'bg-white rounded-lg shadow-lg border border-gray-200',
|
|
115
|
+
'p-4',
|
|
116
|
+
// Width constraints
|
|
117
|
+
'min-w-[280px]',
|
|
118
|
+
// Z-index
|
|
119
|
+
'z-50',
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className={cn('relative inline-flex', className)} {...props}>
|
|
124
|
+
<button
|
|
125
|
+
ref={refs.setReference}
|
|
126
|
+
id={triggerId}
|
|
127
|
+
type="button"
|
|
128
|
+
aria-expanded={isOpen}
|
|
129
|
+
aria-controls={contentId}
|
|
130
|
+
aria-haspopup="dialog"
|
|
131
|
+
aria-label={triggerLabel}
|
|
132
|
+
className={triggerClasses}
|
|
133
|
+
{...getReferenceProps()}
|
|
134
|
+
>
|
|
135
|
+
<Icon className={iconClasses} aria-hidden="true" />
|
|
136
|
+
</button>
|
|
137
|
+
|
|
138
|
+
{isOpen && (
|
|
139
|
+
<FloatingPortal>
|
|
140
|
+
<FloatingFocusManager context={context} modal={false}>
|
|
141
|
+
<div
|
|
142
|
+
ref={refs.setFloating}
|
|
143
|
+
id={contentId}
|
|
144
|
+
aria-labelledby={title ? titleId : undefined}
|
|
145
|
+
aria-label={title ? undefined : 'Information'}
|
|
146
|
+
style={{
|
|
147
|
+
...floatingStyles,
|
|
148
|
+
maxWidth: `min(${maxWidth}, calc(100vw - 32px))`,
|
|
149
|
+
}}
|
|
150
|
+
className={contentClasses}
|
|
151
|
+
{...getFloatingProps()}
|
|
152
|
+
>
|
|
153
|
+
<FloatingArrow ref={arrowRef} context={context} className="fill-white" />
|
|
154
|
+
|
|
155
|
+
{title ? (
|
|
156
|
+
<h2 id={titleId} className="text-sm font-semibold text-gray-900 mb-2">
|
|
157
|
+
{title}
|
|
158
|
+
</h2>
|
|
159
|
+
) : null}
|
|
160
|
+
<div
|
|
161
|
+
className="text-sm text-gray-700 max-h-[min(24rem,calc(100vh-200px))]
|
|
162
|
+
overflow-y-auto"
|
|
163
|
+
>
|
|
164
|
+
{children}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</FloatingFocusManager>
|
|
168
|
+
</FloatingPortal>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
@@ -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';
|
package/src/components/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ export { ErrorFallback } from './ErrorBoundary/ErrorFallback';
|
|
|
51
51
|
// NOTE: Select components moved to separate entry point '@tpzdsp/next-toolkit/components/select'
|
|
52
52
|
// export { Select } from './select/Select';
|
|
53
53
|
// export { SelectSkeleton } from './select/SelectSkeleton';
|
|
54
|
+
// NOTE: InfoBox moved to separate entry point '@tpzdsp/next-toolkit/components/info-box'
|
|
54
55
|
|
|
55
56
|
// Export client component types
|
|
56
57
|
export type { AccordionProps } from './accordion/Accordion';
|
package/src/map/MapComponent.tsx
CHANGED
|
@@ -89,12 +89,8 @@ export const MapComponent = ({
|
|
|
89
89
|
const attribution = new Attribution();
|
|
90
90
|
const layerSwitcher = new LayerSwitcherControl(layers);
|
|
91
91
|
|
|
92
|
-
// Add controls in the desired order
|
|
93
|
-
const controls = [mapZoom, layerSwitcher, scaleLine, attribution];
|
|
94
|
-
|
|
95
92
|
const newMap = new Map({
|
|
96
93
|
target,
|
|
97
|
-
controls,
|
|
98
94
|
layers,
|
|
99
95
|
view: new View({
|
|
100
96
|
projection: 'EPSG:3857',
|
|
@@ -108,12 +104,7 @@ export const MapComponent = ({
|
|
|
108
104
|
}),
|
|
109
105
|
});
|
|
110
106
|
|
|
111
|
-
|
|
112
|
-
// This is a workaround to avoid re-initializing the map when the component
|
|
113
|
-
// re-renders. The map is only initialized once when the component mounts.
|
|
114
|
-
// This is important because the map is a singleton and should not be
|
|
115
|
-
// re-initialized.
|
|
116
|
-
mapInitializedRef.current = true;
|
|
107
|
+
const controls = [mapZoom, layerSwitcher, scaleLine, attribution];
|
|
117
108
|
|
|
118
109
|
// Create an instance of the custom provider, passing any options that are
|
|
119
110
|
// required
|
|
@@ -122,12 +113,23 @@ export const MapComponent = ({
|
|
|
122
113
|
try {
|
|
123
114
|
const geocoder = initializeGeocoder(osMapsApiKey, geocoderUrl, newMap);
|
|
124
115
|
|
|
125
|
-
|
|
116
|
+
// Put geocoder first so the tabbing order is sensible
|
|
117
|
+
controls.unshift(geocoder);
|
|
126
118
|
} catch (error) {
|
|
127
119
|
console.error('Failed to initialize geocoder:', error);
|
|
128
120
|
}
|
|
129
121
|
}
|
|
130
122
|
|
|
123
|
+
// Add controls in the desired order
|
|
124
|
+
controls.forEach((control) => newMap.addControl(control));
|
|
125
|
+
|
|
126
|
+
// Mark the map as initialized to prevent re-initialization
|
|
127
|
+
// This is a workaround to avoid re-initializing the map when the component
|
|
128
|
+
// re-renders. The map is only initialized once when the component mounts.
|
|
129
|
+
// This is important because the map is a singleton and should not be
|
|
130
|
+
// re-initialized.
|
|
131
|
+
mapInitializedRef.current = true;
|
|
132
|
+
|
|
131
133
|
// Setup popup overlay
|
|
132
134
|
const overlay = new Overlay({
|
|
133
135
|
element: document.getElementById('popup-container') ?? undefined,
|