@tpzdsp/next-toolkit 1.13.0 → 1.14.1

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 (51) hide show
  1. package/package.json +13 -1
  2. package/src/components/Button/Button.test.tsx +0 -2
  3. package/src/components/ButtonLink/ButtonLink.stories.tsx +72 -0
  4. package/src/components/ButtonLink/ButtonLink.test.tsx +154 -0
  5. package/src/components/ButtonLink/ButtonLink.tsx +33 -0
  6. package/src/components/ErrorText/ErrorText.test.tsx +0 -2
  7. package/src/components/Heading/Heading.test.tsx +0 -2
  8. package/src/components/Hint/Hint.test.tsx +0 -2
  9. package/src/components/InfoBox/InfoBox.stories.tsx +31 -28
  10. package/src/components/InfoBox/InfoBox.test.tsx +8 -60
  11. package/src/components/InfoBox/InfoBox.tsx +60 -69
  12. package/src/components/LinkButton/LinkButton.stories.tsx +74 -0
  13. package/src/components/LinkButton/LinkButton.test.tsx +177 -0
  14. package/src/components/LinkButton/LinkButton.tsx +80 -0
  15. package/src/components/Modal/Modal.stories.tsx +0 -1
  16. package/src/components/Modal/Modal.test.tsx +1 -3
  17. package/src/components/NotificationBanner/NotificationBanner.test.tsx +0 -2
  18. package/src/components/Paragraph/Paragraph.test.tsx +0 -2
  19. package/src/components/SlidingPanel/SlidingPanel.test.tsx +0 -2
  20. package/src/components/accordion/Accordion.test.tsx +0 -2
  21. package/src/components/backToTop/BackToTop.stories.tsx +0 -2
  22. package/src/components/dropdown/DropdownMenu.test.tsx +0 -2
  23. package/src/components/dropdown/useDropdownMenu.ts +0 -1
  24. package/src/components/form/TextArea.test.tsx +0 -2
  25. package/src/components/images/DefraLogo.tsx +1 -1
  26. package/src/components/index.ts +5 -8
  27. package/src/components/layout/header/Header.stories.tsx +48 -0
  28. package/src/components/layout/header/Header.test.tsx +36 -0
  29. package/src/components/layout/header/HeaderAuthClient.test.tsx +45 -0
  30. package/src/components/layout/header/HeaderNavClient.test.tsx +44 -0
  31. package/src/components/layout/header/HeaderNavClient.tsx +6 -3
  32. package/src/components/link/ExternalLink.test.tsx +102 -0
  33. package/src/components/link/ExternalLink.tsx +1 -0
  34. package/src/http/stream.test.ts +98 -0
  35. package/src/map/MapComponent.tsx +7 -12
  36. package/src/map/geocoder/Geocoder.test.tsx +1 -2
  37. package/src/map/geocoder/Geocoder.tsx +10 -1
  38. package/src/map/osOpenNamesSearch.ts +37 -27
  39. package/src/types/navigation.ts +1 -1
  40. package/src/components/InfoBox/hooks/index.ts +0 -3
  41. package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +0 -187
  42. package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +0 -69
  43. package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +0 -168
  44. package/src/components/InfoBox/hooks/useInfoBoxState.ts +0 -71
  45. package/src/components/InfoBox/hooks/usePortalMount.test.ts +0 -62
  46. package/src/components/InfoBox/hooks/usePortalMount.ts +0 -15
  47. package/src/components/InfoBox/utils/focusTrapConfig.test.ts +0 -310
  48. package/src/components/InfoBox/utils/focusTrapConfig.ts +0 -59
  49. package/src/components/InfoBox/utils/index.ts +0 -2
  50. package/src/components/InfoBox/utils/positionUtils.test.ts +0 -170
  51. package/src/components/InfoBox/utils/positionUtils.ts +0 -89
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { Header } from './Header';
4
+ import type { Credentials } from '../../../types/auth';
5
+ import type { NavLink } from '../../../types/navigation';
6
+
7
+ const navLinks: NavLink[] = [
8
+ { label: 'Home', url: '/', isExternal: false },
9
+ { label: 'API', url: '/api-docs', isExternal: false },
10
+ { label: 'Support', url: 'https://example.com/support', isExternal: true },
11
+ ];
12
+
13
+ const authenticatedCredentials: Credentials = {
14
+ token: 'mock-token',
15
+ user: {
16
+ name: 'Jamie Taylor',
17
+ email: 'jamie.taylor@example.com',
18
+ groupInfoIds: ['group-1', 'group-2'],
19
+ },
20
+ };
21
+
22
+ export default {
23
+ title: 'Components/Layout/Header',
24
+ component: Header,
25
+ parameters: {
26
+ layout: 'fullscreen',
27
+ docs: {
28
+ description: {
29
+ component:
30
+ 'Header with DSP branding, navigation, and auth state. Includes responsive nav and client-side auth links.',
31
+ },
32
+ },
33
+ },
34
+ args: {
35
+ dspUrl: 'https://environment.data.gov.uk',
36
+ appName: 'Wetlands Inventory',
37
+ navLinks,
38
+ credentials: null,
39
+ },
40
+ } as Meta<typeof Header>;
41
+
42
+ export const Default: StoryObj<typeof Header> = {};
43
+
44
+ export const Authenticated: StoryObj<typeof Header> = {
45
+ args: {
46
+ credentials: authenticatedCredentials,
47
+ },
48
+ };
@@ -0,0 +1,36 @@
1
+ import { Header } from './Header';
2
+ import { render, screen } from '../../../test/renderers';
3
+ import type { NavLink } from '../../../types/navigation';
4
+
5
+ const NAV_LINKS: NavLink[] = [
6
+ { label: 'Home', url: '/', isExternal: false },
7
+ { label: 'API', url: '/api-docs', isExternal: false },
8
+ { label: 'Support', url: 'https://example.com/support', isExternal: true },
9
+ ];
10
+
11
+ describe('Header', () => {
12
+ it('renders the primary brand links and app name', async () => {
13
+ render(
14
+ <Header
15
+ credentials={null}
16
+ dspUrl="https://example.com"
17
+ appName="Wetlands Inventory"
18
+ navLinks={NAV_LINKS}
19
+ />,
20
+ );
21
+
22
+ expect(
23
+ screen.getByRole('link', {
24
+ name: /department for environment food & rural affairs/i,
25
+ }),
26
+ ).toBeInTheDocument();
27
+
28
+ expect(screen.getByRole('link', { name: /data services platform/i })).toBeInTheDocument();
29
+ expect(screen.getByRole('link', { name: /wetlands inventory/i })).toBeInTheDocument();
30
+
31
+ const loginLink = await screen.findByRole('link', { name: /login/i });
32
+
33
+ expect(loginLink).toBeInTheDocument();
34
+ expect(screen.getByRole('button', { name: /open menu/i })).toBeInTheDocument();
35
+ });
36
+ });
@@ -0,0 +1,45 @@
1
+ import { HeaderAuthClient } from './HeaderAuthClient';
2
+ import { render, screen } from '../../../test/renderers';
3
+ import type { Credentials } from '../../../types/auth';
4
+
5
+ const AUTH_CREDENTIALS: Credentials = {
6
+ token: 'token-123',
7
+ user: {
8
+ name: 'Jamie Taylor',
9
+ email: 'jamie.taylor@example.com',
10
+ groupInfoIds: ['group-1'],
11
+ },
12
+ };
13
+
14
+ describe('HeaderAuthClient', () => {
15
+ beforeEach(() => {
16
+ globalThis.history.pushState({}, '', '/header-test');
17
+ });
18
+
19
+ it('renders guest state with login link', async () => {
20
+ render(<HeaderAuthClient hostname="https://example.com" credentials={null} />);
21
+
22
+ expect(screen.getByText(/welcome,/i)).toBeInTheDocument();
23
+ expect(screen.getByText(/guest/i)).toBeInTheDocument();
24
+
25
+ const loginLink = await screen.findByRole('link', { name: /login/i });
26
+
27
+ expect(loginLink).toHaveAttribute(
28
+ 'href',
29
+ `https://example.com/login?redirect-uri=${encodeURIComponent(globalThis.location.href)}`,
30
+ );
31
+ });
32
+
33
+ it('renders authenticated state with logout link', async () => {
34
+ render(<HeaderAuthClient hostname="https://example.com" credentials={AUTH_CREDENTIALS} />);
35
+
36
+ expect(screen.getByText(/jamie.taylor@example.com/i)).toBeInTheDocument();
37
+
38
+ const logoutLink = await screen.findByRole('link', { name: /logout/i });
39
+
40
+ expect(logoutLink).toHaveAttribute(
41
+ 'href',
42
+ `https://example.com/api/logout?redirect-uri=${encodeURIComponent(globalThis.location.href)}`,
43
+ );
44
+ });
45
+ });
@@ -0,0 +1,44 @@
1
+ import { HeaderNavClient } from './HeaderNavClient';
2
+ import { render, screen, userEvent } from '../../../test/renderers';
3
+ import type { NavLink } from '../../../types/navigation';
4
+
5
+ const NAV_LINKS: NavLink[] = [
6
+ { label: 'Home', url: '/', isExternal: false },
7
+ { label: 'API', url: '/api-docs', isExternal: false },
8
+ { label: 'Support', url: 'https://example.com/support', isExternal: true },
9
+ ];
10
+
11
+ describe('HeaderNavClient', () => {
12
+ it('renders both navigation regions', () => {
13
+ render(<HeaderNavClient navLinks={NAV_LINKS} />);
14
+
15
+ expect(
16
+ screen.getByRole('navigation', { name: /small screen navigation/i }),
17
+ ).toBeInTheDocument();
18
+ expect(
19
+ screen.getByRole('navigation', { name: /large screen navigation/i }),
20
+ ).toBeInTheDocument();
21
+ });
22
+
23
+ it('shows menu items in the small-screen dropdown', async () => {
24
+ render(<HeaderNavClient navLinks={NAV_LINKS} />);
25
+
26
+ const user = userEvent.setup();
27
+
28
+ await user.click(screen.getByRole('button', { name: /open menu/i }));
29
+
30
+ expect(await screen.findByRole('menuitem', { name: /home/i })).toBeInTheDocument();
31
+ expect(await screen.findByRole('menuitem', { name: /api/i })).toBeInTheDocument();
32
+ expect(await screen.findByRole('menuitem', { name: /support/i })).toBeInTheDocument();
33
+ });
34
+
35
+ it('renders large-screen links for navigation', () => {
36
+ render(<HeaderNavClient navLinks={NAV_LINKS} />);
37
+
38
+ const supportLink = screen.getByRole('link', { name: /support/i });
39
+
40
+ expect(supportLink).toHaveAttribute('href', 'https://example.com/support');
41
+ expect(screen.getByRole('link', { name: /home/i })).toBeInTheDocument();
42
+ expect(screen.getByRole('link', { name: /api/i })).toBeInTheDocument();
43
+ });
44
+ });
@@ -23,12 +23,15 @@ const InternalNavItem = ({ label, url, icon, ...props }: DropdownMenuItem<NavLin
23
23
  </Link>
24
24
  );
25
25
 
26
- const NavItem = ({ isExternal, ...props }: DropdownMenuItem<NavLink>) => {
26
+ const NavItem = ({ url, openInNewTab, ...props }: DropdownMenuItem<NavLink>) => {
27
+ const isExternal = /^https?:\/\//.test(url);
28
+ const newTabProps = openInNewTab ? { target: '_blank' as const, rel: 'noopener noreferrer' } : {};
29
+
27
30
  if (isExternal) {
28
- return <ExternalNavItem {...props} />;
31
+ return <ExternalNavItem url={url} {...newTabProps} {...props} />;
29
32
  }
30
33
 
31
- return <InternalNavItem {...props} />;
34
+ return <InternalNavItem url={url} {...newTabProps} {...props} />;
32
35
  };
33
36
 
34
37
  export const HeaderNavClient = ({ navLinks }: HeaderNavClientProps) => {
@@ -0,0 +1,102 @@
1
+ import { render, screen } from '@testing-library/react';
2
+
3
+ import { ExternalLink } from './ExternalLink';
4
+
5
+ describe('ExternalLink', () => {
6
+ it('renders with children', () => {
7
+ render(<ExternalLink href="https://example.com">Visit Example</ExternalLink>);
8
+
9
+ const link = screen.getByRole('link', { name: /Visit Example/i });
10
+
11
+ expect(link).toBeInTheDocument();
12
+ });
13
+
14
+ it('sets target="_blank" to open in new tab', () => {
15
+ render(<ExternalLink href="https://example.com">External Link</ExternalLink>);
16
+
17
+ const link = screen.getByRole('link');
18
+
19
+ expect(link).toHaveAttribute('target', '_blank');
20
+ });
21
+
22
+ it('sets rel="noopener noreferrer" for security', () => {
23
+ render(<ExternalLink href="https://example.com">External Link</ExternalLink>);
24
+
25
+ const link = screen.getByRole('link');
26
+
27
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
28
+ });
29
+
30
+ it('includes screen reader text indicating new tab', () => {
31
+ render(<ExternalLink href="https://example.com">Visit Site</ExternalLink>);
32
+
33
+ // The accessible name should include the "(opens in new tab)" text
34
+ const link = screen.getByRole('link', { name: /Visit Site\(opens in new tab\)/i });
35
+
36
+ expect(link).toBeInTheDocument();
37
+ });
38
+
39
+ it('applies custom className', () => {
40
+ render(
41
+ <ExternalLink href="https://example.com" className="custom-class">
42
+ Link
43
+ </ExternalLink>,
44
+ );
45
+
46
+ const link = screen.getByRole('link');
47
+
48
+ expect(link).toHaveClass('custom-class');
49
+ });
50
+
51
+ it('preserves default link styling classes', () => {
52
+ render(<ExternalLink href="https://example.com">Link</ExternalLink>);
53
+
54
+ const link = screen.getByRole('link');
55
+
56
+ expect(link).toHaveClass('text-link');
57
+ expect(link).toHaveClass('underline');
58
+ });
59
+
60
+ it('applies href attribute correctly', () => {
61
+ render(<ExternalLink href="https://example.com">Link</ExternalLink>);
62
+
63
+ const link = screen.getByRole('link');
64
+
65
+ expect(link).toHaveAttribute('href', 'https://example.com');
66
+ });
67
+
68
+ it('forwards additional props to anchor element', () => {
69
+ render(
70
+ <ExternalLink href="https://example.com" data-testid="external-link">
71
+ Link
72
+ </ExternalLink>,
73
+ );
74
+
75
+ const link = screen.getByTestId('external-link');
76
+
77
+ expect(link).toBeInTheDocument();
78
+ });
79
+
80
+ it('renders with complex children', () => {
81
+ render(
82
+ <ExternalLink href="https://example.com">
83
+ <span>Complex </span>
84
+
85
+ <strong>Content</strong>
86
+ </ExternalLink>,
87
+ );
88
+
89
+ const link = screen.getByRole('link');
90
+
91
+ expect(link).toBeInTheDocument();
92
+ expect(link).toHaveTextContent('Complex Content');
93
+ });
94
+
95
+ it('screen reader text is visually hidden but accessible', () => {
96
+ render(<ExternalLink href="https://example.com">Link</ExternalLink>);
97
+
98
+ const srText = screen.getByText('(opens in new tab)', { exact: false });
99
+
100
+ expect(srText).toHaveClass('sr-only');
101
+ });
102
+ });
@@ -24,5 +24,6 @@ export const ExternalLink = ({ href, className, children, ...props }: ExternalLi
24
24
  target="_blank"
25
25
  >
26
26
  {children}
27
+ <span className="sr-only"> (opens in new tab)</span>
27
28
  </a>
28
29
  );
@@ -0,0 +1,98 @@
1
+ import { readJsonXLinesStream } from './stream';
2
+
3
+ const createMockReader = (chunks: Uint8Array[]) => {
4
+ let index = 0;
5
+
6
+ return {
7
+ read: vi.fn(async () => {
8
+ if (index < chunks.length) {
9
+ return { value: chunks[index++], done: false };
10
+ }
11
+
12
+ return { value: undefined, done: true };
13
+ }),
14
+ releaseLock: vi.fn(),
15
+ };
16
+ };
17
+
18
+ const createMockResponse = (chunks: string[]): Response => {
19
+ const encoder = new TextEncoder();
20
+ const uintChunks = chunks.map((c) => encoder.encode(c));
21
+
22
+ const mockReader = createMockReader(uintChunks);
23
+
24
+ return {
25
+ status: 200,
26
+ body: {
27
+ getReader: () => mockReader,
28
+ },
29
+ } as unknown as Response;
30
+ };
31
+
32
+ describe('readJsonXLinesStream', () => {
33
+ it('parses valid NDJSON chunks', async () => {
34
+ const response = createMockResponse([
35
+ '{"id":1,"name":"Alice"}\n{"id":2,',
36
+ '"name":"Bob"}\n{"id":3,"name":"Charlie"}\n',
37
+ ]);
38
+
39
+ const result = await readJsonXLinesStream(response);
40
+
41
+ expect(result).toEqual([
42
+ { id: 1, name: 'Alice' },
43
+ { id: 2, name: 'Bob' },
44
+ { id: 3, name: 'Charlie' },
45
+ ]);
46
+ });
47
+
48
+ it('ignores invalid JSON lines', async () => {
49
+ const response = createMockResponse([
50
+ '{"id":1,"name":"Alice"}\nINVALID_JSON\n{"id":2,"name":"Bob"}\n',
51
+ ]);
52
+
53
+ const result = await readJsonXLinesStream(response);
54
+
55
+ expect(result).toEqual([
56
+ { id: 1, name: 'Alice' },
57
+ { id: 2, name: 'Bob' },
58
+ ]);
59
+ });
60
+
61
+ it('handles trailing line without newline', async () => {
62
+ const response = createMockResponse(['{"id":1,"name":"Alice"}\n{"id":2,"name":"Bob"}']);
63
+
64
+ const result = await readJsonXLinesStream(response);
65
+
66
+ expect(result).toEqual([
67
+ { id: 1, name: 'Alice' },
68
+ { id: 2, name: 'Bob' },
69
+ ]);
70
+ });
71
+
72
+ it('returns empty array when no valid data', async () => {
73
+ const response = createMockResponse(['\n\n']);
74
+
75
+ const result = await readJsonXLinesStream(response);
76
+
77
+ expect(result).toEqual([]);
78
+ });
79
+
80
+ it('skips whitespace-only lines', async () => {
81
+ const response = createMockResponse([' \n{"id":1,"name":"Alice"}\n \n']);
82
+
83
+ const result = await readJsonXLinesStream(response);
84
+
85
+ expect(result).toEqual([{ id: 1, name: 'Alice' }]);
86
+ });
87
+
88
+ it('logs a warning for invalid JSON', async () => {
89
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
90
+ const response = createMockResponse(['INVALID\n']);
91
+
92
+ await readJsonXLinesStream(response);
93
+
94
+ expect(warnSpy).toHaveBeenCalledTimes(1);
95
+
96
+ warnSpy.mockRestore();
97
+ });
98
+ });
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { memo, useEffect, useRef, useState } from 'react';
4
4
 
5
5
  import { Map, Overlay, View } from 'ol';
6
6
  import { Attribution, ScaleLine, Zoom } from 'ol/control';
@@ -17,7 +17,6 @@ import type { PopupDirection } from '../types/map';
17
17
  export type MapComponentProps = {
18
18
  osMapsApiKey?: string;
19
19
  basePath: string;
20
- isLoading?: boolean;
21
20
  };
22
21
 
23
22
  const positionTransforms: Record<PopupDirection, string> = {
@@ -39,9 +38,12 @@ const arrowStyles: Record<PopupDirection, string> = {
39
38
  * and basic controls are added to the map. The map component encapsulates
40
39
  * a number of child components, used to interact with the map itself.
41
40
  *
41
+ * Memoized to prevent unnecessary re-renders that could conflict with
42
+ * OpenLayers' imperative DOM manipulation.
43
+ *
42
44
  * @return {*}
43
45
  */
44
- export const MapComponent = ({ osMapsApiKey, basePath, isLoading }: MapComponentProps) => {
46
+ const MapComponentBase = ({ osMapsApiKey, basePath }: MapComponentProps) => {
45
47
  const [popupFeatures, setPopupFeatures] = useState([]);
46
48
  const [popupCoordinate, setPopupCoordinate] = useState<number[] | null>(null);
47
49
  const [popupPositionClass, setPopupPositionClass] = useState<PopupDirection>('bottom-right');
@@ -155,15 +157,6 @@ export const MapComponent = ({ osMapsApiKey, basePath, isLoading }: MapComponent
155
157
  // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
156
158
  tabIndex={0}
157
159
  >
158
- {isLoading ? (
159
- <div className="absolute inset-0 flex items-center justify-center bg-white/50 z-10">
160
- <div
161
- className="w-9 h-9 border-4 border-gray-200 border-t-blue-500 rounded-full
162
- animate-spin"
163
- />
164
- </div>
165
- ) : null}
166
-
167
160
  <div
168
161
  className={`absolute z-20 ${positionTransforms[popupPositionClass]}`}
169
162
  id="popup-container"
@@ -182,3 +175,5 @@ export const MapComponent = ({ osMapsApiKey, basePath, isLoading }: MapComponent
182
175
  </div>
183
176
  );
184
177
  };
178
+
179
+ export const MapComponent = memo(MapComponentBase);
@@ -1,12 +1,11 @@
1
1
  import Map from 'ol/Map';
2
2
  import View from 'ol/View';
3
- import { describe, expect, it, vi } from 'vitest';
4
3
 
5
4
  import { Geocoder } from './Geocoder';
6
5
  import { render, screen, userEvent } from '../../test/renderers';
7
6
 
8
7
  class MockView extends View {
9
- animate = vi.fn((options, callback) => {
8
+ animate = vi.fn((_, callback) => {
10
9
  // Immediately execute callback if provided (simulating instant animation)
11
10
  if (typeof callback === 'function') {
12
11
  callback(true);
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useId, useRef, useState } from 'react';
3
+ import { useEffect, useId, useRef, useState } from 'react';
4
4
 
5
5
  import type Map from 'ol/Map';
6
6
 
@@ -129,6 +129,15 @@ export const Geocoder = ({
129
129
  const [status, setStatus] = useState('');
130
130
  const [isSearching, setIsSearching] = useState(false);
131
131
 
132
+ // Scroll the active result into view when navigating with arrow keys
133
+ useEffect(() => {
134
+ if (activeIndex >= 0) {
135
+ document
136
+ .getElementById(`${listboxId}-option-${activeIndex}`)
137
+ ?.scrollIntoView({ block: 'nearest' });
138
+ }
139
+ }, [activeIndex, listboxId]);
140
+
132
141
  const performSearch = async () => {
133
142
  if (query.length < minChars) {
134
143
  setStatus(`Enter at least ${minChars} characters`);
@@ -63,21 +63,6 @@ export const createOsOpenNamesSearch = (options: OsOpenNamesSearchOptions) => {
63
63
 
64
64
  return features
65
65
  .map((feature, index): GeocoderResult | null => {
66
- if (feature.geometry.type !== 'Point') {
67
- console.error('Geometry type is not Point');
68
-
69
- return null;
70
- }
71
-
72
- const [lon, lat] = feature.geometry.coordinates;
73
-
74
- // Type guard: ensure coordinates are valid numbers
75
- if (typeof lon !== 'number' || typeof lat !== 'number') {
76
- console.error('Invalid coordinates: lon or lat is not a number');
77
-
78
- return null;
79
- }
80
-
81
66
  const properties = feature.properties ?? {};
82
67
 
83
68
  // Handle address which can be an object or string
@@ -88,35 +73,60 @@ export const createOsOpenNamesSearch = (options: OsOpenNamesSearchOptions) => {
88
73
 
89
74
  label = [addr.name, addr.town, addr.country].filter(Boolean).join(', ');
90
75
  } else {
91
- label = properties.address ?? properties.name ?? `${lat}, ${lon}`;
92
- }
93
-
94
- const [x, y] = transform([lon, lat], EPSG_4326, EPSG_3857);
95
-
96
- // Type guard: ensure transformed coordinates are valid numbers
97
- if (typeof x !== 'number' || typeof y !== 'number') {
98
- console.error('Transform failed: x or y is not a number');
99
-
100
- return null;
76
+ label = properties.address ?? properties.name ?? `result-${index}`;
101
77
  }
102
78
 
103
79
  const result: GeocoderResult = {
104
80
  id: properties.id ?? `result-${index}`,
105
81
  label,
106
82
  group: properties.type ?? properties.localType,
107
- center: [x, y],
108
83
  // Assign zoom level based on type, fallback to 14
109
84
  zoom: ZOOM_BY_TYPE[properties.type] ?? ZOOM_BY_TYPE[properties.localType] ?? 14,
110
85
  };
111
86
 
112
- // Transform bbox to map projection if available
87
+ // Prefer bbox works for all geometry types (Point, Polygon, MultiPolygon)
113
88
  if (feature.bbox && Array.isArray(feature.bbox) && feature.bbox.length === 4) {
89
+ const [minLon, minLat, maxLon, maxLat] = feature.bbox;
90
+
114
91
  result.extent = transformExtent(feature.bbox, EPSG_4326, EPSG_3857) as [
115
92
  number,
116
93
  number,
117
94
  number,
118
95
  number,
119
96
  ];
97
+
98
+ const [x, y] = transform(
99
+ [(minLon + maxLon) / 2, (minLat + maxLat) / 2],
100
+ EPSG_4326,
101
+ EPSG_3857,
102
+ );
103
+
104
+ if (typeof x === 'number' && typeof y === 'number') {
105
+ result.center = [x, y];
106
+ }
107
+ } else if (feature.geometry.type === 'Point') {
108
+ // Fallback for Point features without a bbox
109
+ const [lon, lat] = feature.geometry.coordinates;
110
+
111
+ if (typeof lon !== 'number' || typeof lat !== 'number') {
112
+ console.error('Invalid coordinates: lon or lat is not a number');
113
+
114
+ return null;
115
+ }
116
+
117
+ const [x, y] = transform([lon, lat], EPSG_4326, EPSG_3857);
118
+
119
+ if (typeof x !== 'number' || typeof y !== 'number') {
120
+ console.error('Transform failed: x or y is not a number');
121
+
122
+ return null;
123
+ }
124
+
125
+ result.center = [x, y];
126
+ } else {
127
+ console.error('Feature has no bbox and geometry is not a Point');
128
+
129
+ return null;
120
130
  }
121
131
 
122
132
  return result;
@@ -3,6 +3,6 @@ import type { ReactNode } from 'react';
3
3
  export type NavLink = {
4
4
  label: string;
5
5
  url: string;
6
- isExternal?: boolean;
6
+ openInNewTab?: boolean;
7
7
  icon?: ReactNode;
8
8
  };
@@ -1,3 +0,0 @@
1
- export { useInfoBoxPosition } from './useInfoBoxPosition';
2
- export { useInfoBoxState } from './useInfoBoxState';
3
- export { usePortalMount } from './usePortalMount';