@tpzdsp/next-toolkit 1.15.2 → 2.0.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 CHANGED
@@ -5,7 +5,7 @@ A modern, reusable React component library designed specifically for Next.js app
5
5
  ## 📋 Prerequisites
6
6
 
7
7
  - **Node.js**: 22.17.1 (managed with `asdf`)
8
- - **Package Manager**: Yarn (preferred) or npm
8
+ - **Package Manager**: pnpm (preferred) or npm
9
9
  - **Next.js**: 13+ (peer dependency)
10
10
  - **React**: 18+ (peer dependency)
11
11
 
@@ -23,11 +23,11 @@ A modern, reusable React component library designed specifically for Next.js app
23
23
  ## Installation
24
24
 
25
25
  ```bash
26
- yarn add @your-org/nextjs-library
26
+ pnpm add @your-org/nextjs-library
27
27
  # or
28
28
  npm install @your-org/nextjs-library
29
29
  # or
30
- pnpm add @your-org/nextjs-library
30
+ yarn add @your-org/nextjs-library
31
31
  ```
32
32
 
33
33
  ## Usage
@@ -239,25 +239,25 @@ This library distributes **raw TypeScript/TSX files** instead of compiled bundle
239
239
 
240
240
  ```bash
241
241
  # Install dependencies
242
- yarn install
242
+ pnpm install
243
243
 
244
244
  # Start development server
245
- yarn dev
245
+ pnpm dev
246
246
 
247
247
  # Run tests
248
- yarn test
248
+ pnpm test
249
249
 
250
250
  # Run tests with UI
251
- yarn test:ui
251
+ pnpm test:ui
252
252
 
253
253
  # Build the library (TypeScript check only)
254
- yarn build
254
+ pnpm build
255
255
 
256
256
  # Build distribution files (if needed)
257
- yarn build:dist
257
+ pnpm build:dist
258
258
 
259
259
  # Preview the built library
260
- yarn preview
260
+ pnpm preview
261
261
  ```
262
262
 
263
263
  ## Local Development with Yalc
@@ -285,7 +285,7 @@ yalc publish
285
285
  yalc add @tpzdsp/next-toolkit
286
286
 
287
287
  # Install dependencies
288
- yarn install
288
+ pnpm install
289
289
  ```
290
290
 
291
291
  ### Your Next.js App Configuration
@@ -313,7 +313,7 @@ module.exports = nextConfig;
313
313
  yalc push
314
314
 
315
315
  # 3. In your test app, clear Next.js cache and restart
316
- rm -rf .next && yarn dev
316
+ rm -rf .next && pnpm dev
317
317
  ```
318
318
 
319
319
  ### Why No Hot Reloading?
@@ -327,7 +327,7 @@ rm -rf .next && yarn dev
327
327
  ```bash
328
328
  # In your Next.js app directory
329
329
  yalc remove @tpzdsp/next-toolkit
330
- yarn install
330
+ pnpm install
331
331
  ```
332
332
 
333
333
  ### Benefits Despite Manual Process
@@ -464,13 +464,13 @@ This library uses Vitest and React Testing Library for testing:
464
464
 
465
465
  ```bash
466
466
  # Run all tests
467
- yarn test
467
+ pnpm test
468
468
 
469
469
  # Run tests in watch mode
470
- yarn test:watch
470
+ pnpm test:watch
471
471
 
472
472
  # Run tests with coverage
473
- yarn test:coverage
473
+ pnpm test:coverage
474
474
  ```
475
475
 
476
476
  ## Build Configuration
@@ -543,12 +543,12 @@ import '@your-org/nextjs-library/src/assets/styles/globals.css';
543
543
  2. **Install dependencies:**
544
544
 
545
545
  ```bash
546
- yarn install
546
+ pnpm install
547
547
  ```
548
548
 
549
549
  3. **Run tests to ensure everything works:**
550
550
  ```bash
551
- yarn test
551
+ pnpm test
552
552
  ```
553
553
 
554
554
  ### Development Workflow
@@ -557,7 +557,7 @@ import '@your-org/nextjs-library/src/assets/styles/globals.css';
557
557
  2. Create your feature branch (`git checkout -b feature/amazing-feature`)
558
558
  3. Make your changes following our component templates above
559
559
  4. Add tests for your changes (`src/test/YourComponent.test.tsx`)
560
- 5. Run the test suite (`yarn test`)
560
+ 5. Run the test suite (`pnpm test`)
561
561
  6. Test with yalc in a real Next.js app
562
562
  7. Commit your changes (`git commit -m 'Add some amazing feature'`)
563
563
  8. Push to the branch (`git push origin feature/amazing-feature`)
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.15.2",
3
+ "version": "2.0.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "engines": {
6
- "node": ">= 24.12.0",
7
- "yarn": ">=1.22.22"
6
+ "node": "^24.12.0",
7
+ "pnpm": "^10.0.0"
8
8
  },
9
9
  "type": "module",
10
10
  "private": false,
@@ -121,67 +121,50 @@
121
121
  "dev:push": "yalc push",
122
122
  "tsc:check": "tsc -b --noEmit",
123
123
  "storybook": "storybook dev -p 6006",
124
- "release": "semantic-release"
124
+ "release": "semantic-release",
125
+ "postinstall": "if [ \"$CI\" != \"true\" ]; then ./postinstall.sh; fi"
125
126
  },
126
127
  "devDependencies": {
127
128
  "@better-fetch/fetch": "^1.1.21",
128
- "@eslint/js": "^9.30.1",
129
- "@semantic-release/changelog": "^6.0.3",
130
- "@semantic-release/git": "^10.0.1",
131
- "@semantic-release/github": "^12.0.2",
132
- "@storybook/addon-docs": "10.2.0",
133
- "@storybook/addon-links": "10.2.0",
134
- "@storybook/addon-onboarding": "10.2.0",
135
- "@storybook/react-vite": "10.2.0",
129
+ "@floating-ui/react": "^0.27.17",
130
+ "@storybook/addon-docs": "10.3.5",
131
+ "@storybook/addon-links": "10.3.5",
132
+ "@storybook/addon-onboarding": "10.3.5",
133
+ "@storybook/react-vite": "10.3.5",
136
134
  "@tailwindcss/typography": "^0.5.16",
137
135
  "@tanstack/react-query": "^5.90.19",
138
136
  "@testing-library/dom": "^10.4.1",
139
137
  "@testing-library/jest-dom": "^6.6.4",
140
138
  "@testing-library/react": "^16.3.0",
141
139
  "@testing-library/user-event": "^14.6.1",
142
- "@tpzdsp/eslint-config-dsp": "^2.0.0",
140
+ "@tpzdsp/eslint-config-dsp": "^4.0.0",
143
141
  "@turf/turf": "^7.2.0",
144
142
  "@types/geojson": "^7946.0.16",
145
143
  "@types/jsonwebtoken": "^9.0.10",
146
- "@types/node": "^24.10.9",
144
+ "@types/node": "^24.12.2",
147
145
  "@types/react": "^19.1.8",
148
146
  "@types/react-dom": "^19.1.6",
149
- "@typescript-eslint/eslint-plugin": "^8.38.0",
150
- "@typescript-eslint/parser": "^8.38.0",
151
- "@vitejs/plugin-react": "^5.1.2",
147
+ "@vitejs/plugin-react": "^6.0.1",
152
148
  "@vitest/coverage-v8": "^4.0.17",
153
149
  "@vitest/eslint-plugin": "^1.3.4",
154
150
  "autoprefixer": "^10.4.21",
155
151
  "buffer": "^6.0.3",
156
152
  "crypto-browserify": "^3.12.1",
157
153
  "date-fns": "^4.1.0",
158
- "eslint": "^9.30.1",
159
- "eslint-config-prettier": "^10.1.8",
160
- "eslint-import-resolver-alias": "^1.1.2",
161
- "eslint-import-resolver-typescript": "^4.4.4",
162
- "eslint-plugin-import-x": "^4.16.1",
163
- "eslint-plugin-jest-dom": "^5.5.0",
164
- "eslint-plugin-jsx-a11y": "^6.10.2",
165
- "eslint-plugin-prettier": "^5.5.5",
166
- "eslint-plugin-react": "^7.37.5",
167
- "eslint-plugin-react-hooks": "^7.0.1",
168
- "eslint-plugin-react-refresh": "^0.4.20",
169
- "eslint-plugin-sonarjs": "^3.0.5",
170
- "eslint-plugin-storybook": "10.2.0",
171
- "focus-trap": "^7.6.5",
172
- "focus-trap-react": "^11.0.6",
154
+ "eslint": "^9.39.4",
155
+ "eslint-plugin-storybook": "10.3.5",
156
+ "focus-trap": "^8.0.1",
157
+ "focus-trap-react": "^12.0.0",
173
158
  "geojson": "^0.5.0",
174
159
  "globals": "^17.0.0",
175
160
  "husky": "^9.1.7",
176
- "jsdom": "^27.4.0",
161
+ "jsdom": "^29.0.2",
177
162
  "jsonwebtoken": "^9.0.3",
178
163
  "next": "^16.1.4",
179
164
  "ol": "^10.6.1",
180
165
  "ol-mapbox-style": "^13.0.1",
181
166
  "postcss": "^8.5.6",
182
167
  "prettier": "^3.8.1",
183
- "prettier-plugin-classnames": "^0.9.0",
184
- "prettier-plugin-tailwindcss": "^0.7.2",
185
168
  "process": "^0.11.10",
186
169
  "proj4": "^2.19.10",
187
170
  "react": "^19.2.1",
@@ -192,58 +175,50 @@
192
175
  "react-select-event": "^5.5.1",
193
176
  "rollup-plugin-peer-deps-external": "^2.2.4",
194
177
  "semantic-release": "^25.0.2",
195
- "storybook": "10.2.0",
178
+ "storybook": "10.3.5",
196
179
  "stream-browserify": "^3.0.0",
197
180
  "tailwind-merge": "^3.4.0",
198
181
  "tailwindcss": "^3.4.16",
199
- "typescript": "^5.9.3",
182
+ "typescript": "^6.0.2",
200
183
  "typescript-eslint": "^8.35.1",
201
184
  "util": "^0.12.5",
202
- "vite": "^7.0.4",
185
+ "vite": "^8.0.8",
203
186
  "vite-plugin-dts": "^4.5.4",
204
187
  "vitest": "^4.0.17",
205
188
  "zod": "^4.3.5"
206
189
  },
207
190
  "peerDependencies": {
208
- "@better-fetch/fetch": "^1.1.21",
209
- "@floating-ui/react": "^0.27.17",
210
- "@tanstack/react-query": "^5.90.19",
211
- "@testing-library/react": "^16.0.0",
212
- "@testing-library/user-event": "^14.6.1",
213
- "@turf/turf": "^7.2.0",
214
- "@types/geojson": "^7946.0.16",
215
- "@types/jsonwebtoken": "^9.0.10",
216
- "date-fns": "^4.1.0",
217
- "focus-trap": "^7.6.5",
218
- "focus-trap-react": "^11.0.6",
219
- "geojson": "^0.5.0",
220
- "next": "^16.1.4",
221
- "ol": "^10.6.1",
222
- "ol-mapbox-style": "^13.0.1",
223
- "proj4": "^2.19.10",
224
- "react": "^19.2.1",
225
- "react-dom": "^19.2.1",
226
- "react-error-boundary": "^6.0.0",
227
- "react-icons": "^5.5.0",
228
- "zod": "^4.3.5"
191
+ "@better-fetch/fetch": "^1",
192
+ "@floating-ui/react": "^0.27",
193
+ "@tanstack/react-query": "^5",
194
+ "@turf/turf": "^7",
195
+ "date-fns": "^4",
196
+ "focus-trap": "^8",
197
+ "focus-trap-react": "^12",
198
+ "geojson": "^0.5",
199
+ "jsonwebtoken": "^9",
200
+ "next": "^16",
201
+ "ol": "^10",
202
+ "ol-mapbox-style": "^13",
203
+ "proj4": "^2",
204
+ "react": "^19",
205
+ "react-dom": "^19",
206
+ "react-error-boundary": "^6",
207
+ "react-icons": "^5",
208
+ "react-select": "^5",
209
+ "zod": "^4"
229
210
  },
230
211
  "peerDependenciesMeta": {
231
- "@tanstack/react-query": {
232
- "optional": true
233
- },
234
212
  "@better-fetch/fetch": {
235
213
  "optional": true
236
214
  },
237
215
  "@floating-ui/react": {
238
216
  "optional": true
239
217
  },
240
- "@turf/turf": {
241
- "optional": true
242
- },
243
- "@types/geojson": {
218
+ "@tanstack/react-query": {
244
219
  "optional": true
245
220
  },
246
- "@types/jsonwebtoken": {
221
+ "@turf/turf": {
247
222
  "optional": true
248
223
  },
249
224
  "date-fns": {
@@ -277,13 +252,7 @@
277
252
  "optional": true
278
253
  }
279
254
  },
280
- "optionalDependencies": {
281
- "react-select": "^5.10.2"
282
- },
283
255
  "release": {
284
256
  "extends": "./release.config.js"
285
- },
286
- "dependencies": {
287
- "@floating-ui/react": "^0.27.17"
288
257
  }
289
258
  }
@@ -134,7 +134,7 @@ export const InfoBox = ({
134
134
  <Icon className={iconClasses} aria-hidden="true" />
135
135
  </button>
136
136
 
137
- {isOpen && (
137
+ {isOpen ? (
138
138
  <FloatingPortal>
139
139
  <FloatingFocusManager context={context} modal={false}>
140
140
  <div
@@ -165,7 +165,7 @@ export const InfoBox = ({
165
165
  </div>
166
166
  </FloatingFocusManager>
167
167
  </FloatingPortal>
168
- )}
168
+ ) : null}
169
169
  </div>
170
170
  );
171
171
  };
@@ -1,6 +1,14 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, type ReactNode, useRef, useMemo, useId } from 'react';
3
+ import {
4
+ useState,
5
+ useEffect,
6
+ type ReactNode,
7
+ useRef,
8
+ useMemo,
9
+ useId,
10
+ type CSSProperties,
11
+ } from 'react';
4
12
 
5
13
  import { FocusTrap } from 'focus-trap-react';
6
14
 
@@ -115,7 +123,7 @@ export const SlidingPanel = ({
115
123
  }[position];
116
124
 
117
125
  // Dynamic positioning using actual panel dimensions
118
- const getButtonStyle = useMemo(() => {
126
+ const getButtonStyle = useMemo((): CSSProperties => {
119
127
  if (position === 'center-left') {
120
128
  return {
121
129
  left: isVisible ? `${panelDimensions.width}px` : '0px',
@@ -188,7 +196,7 @@ export const SlidingPanel = ({
188
196
 
189
197
  return true; // still allow it to deactivate
190
198
  },
191
- // eslint-disable-next-line sonarjs/function-return-type
199
+
192
200
  setReturnFocus: (target) => {
193
201
  // if the user has clicked outside the focus trap we shouldn't return focus as it will steal focus from the element they clicked on
194
202
  return deactivatedByClick.current ? false : target;
@@ -310,14 +310,14 @@ export const LongPageExample: Story = {
310
310
  fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa
311
311
  qui officia deserunt mollit anim id est laborum.
312
312
  </p>
313
- {i % 10 === 9 && (
313
+ {i % 10 === 9 ? (
314
314
  <div className="mt-4 p-3 bg-gray-100 rounded">
315
315
  <p className="text-sm text-gray-600">
316
316
  📍 Checkpoint: You&apos;ve scrolled through {i + 1} articles. Use the back to
317
317
  top button to return to the beginning!
318
318
  </p>
319
319
  </div>
320
- )}
320
+ ) : null}
321
321
  </article>
322
322
  ))}
323
323
  </section>
@@ -102,7 +102,7 @@ export const BackToTop = ({
102
102
  );
103
103
 
104
104
  if (!isVisible) {
105
- return null;
105
+ return <></>;
106
106
  }
107
107
 
108
108
  return (
@@ -26,7 +26,7 @@ export const HeaderAuthClient = ({ hostname, credentials }: HeaderAuthClientProp
26
26
  <span>Welcome,</span> {credentials ? credentials.user.email : 'Guest'}
27
27
  </span>
28
28
 
29
- {isClient && (
29
+ {isClient ? (
30
30
  <Link
31
31
  href={`${hostname}/${credentials ? 'api/logout' : 'login'}?redirect-uri=${encodeURIComponent(window.location.href)}`}
32
32
  className="text-white visited:text-white active:text-black hover:text-white"
@@ -34,7 +34,7 @@ export const HeaderAuthClient = ({ hostname, credentials }: HeaderAuthClientProp
34
34
  >
35
35
  <span className="text-base">{credentials ? 'Logout' : 'Login'}</span>
36
36
  </Link>
37
- )}
37
+ ) : null}
38
38
  </span>
39
39
  );
40
40
  };
@@ -20,18 +20,21 @@ export const MimeType = {
20
20
  */
21
21
  export type MimeTypes = (typeof MimeType)[keyof typeof MimeType];
22
22
 
23
+ const jsonMimeTypes = [MimeType.Json, MimeType.GeoJson, MimeType.JsonLd] as const;
24
+ const textMimeTypes = [MimeType.Text, MimeType.Csv] as const;
25
+
23
26
  /**
24
27
  * Returns `true` if the mime type is a non-streamed JSON mime type (I.E. not JSON X-Lines).
25
28
  */
26
29
  export const isJsonMimeType = (mime: MimeTypes | string | null): boolean => {
27
- return ([MimeType.Json, MimeType.GeoJson, MimeType.JsonLd] as string[]).includes(mime ?? '');
30
+ return mime != null && jsonMimeTypes.includes(mime as (typeof jsonMimeTypes)[number]);
28
31
  };
29
32
 
30
33
  /**
31
34
  * Returns `true` if the mime type is a plain-text mime type.
32
35
  */
33
36
  export const isTextMimeType = (mime: MimeTypes | string | null): boolean => {
34
- return ([MimeType.Text, MimeType.Csv] as string[]).includes(mime ?? '');
37
+ return mime != null && textMimeTypes.includes(mime as (typeof textMimeTypes)[number]);
35
38
  };
36
39
 
37
40
  /**
@@ -52,6 +55,8 @@ export const HttpStatus = {
52
55
  InternalServerError: 500,
53
56
  NotImplemented: 501,
54
57
  BadGateway: 502,
58
+ ServiceUnavailable: 503,
59
+ GatewayTimeout: 504,
55
60
  } as const;
56
61
 
57
62
  /**
@@ -7,7 +7,7 @@ const MAX_PREVIEW_LENGTH = 150;
7
7
  // Tries to print data as a string.
8
8
  const stringify = (data: unknown): string | null => {
9
9
  if (data == null) {
10
- return null;
10
+ return '';
11
11
  }
12
12
 
13
13
  try {
@@ -22,7 +22,7 @@ const preview = (data: unknown): string | null => {
22
22
  const str = stringify(data);
23
23
 
24
24
  if (str == null) {
25
- return null;
25
+ return '';
26
26
  }
27
27
 
28
28
  if (str.length > MAX_PREVIEW_LENGTH) {
@@ -36,7 +36,7 @@ type RequestMeta = {
36
36
  requestId: string;
37
37
  };
38
38
 
39
- type RequestLoggerMessageExtras = {
39
+ export type RequestLoggerMessageExtras = {
40
40
  loggerOverrides?: {
41
41
  requestFormat?: (args: { request: MetaContext }) => string;
42
42
 
@@ -191,7 +191,7 @@ const formatBodyLine = ({
191
191
  const rendered = truncateBody ? preview(body) : stringify(body);
192
192
 
193
193
  if (!rendered) {
194
- return null;
194
+ return '';
195
195
  }
196
196
 
197
197
  return truncateBody ? `\t↳ ${label}: ${rendered}` : `\t↳ ${label}:\n${rendered}`;
@@ -48,7 +48,7 @@ export const LayerSwitcherPanel = ({
48
48
  }, [isOpen]);
49
49
 
50
50
  if (!isMounted || !isOpen) {
51
- return null;
51
+ return <></>;
52
52
  }
53
53
 
54
54
  // Calculate position based on button position
@@ -135,26 +135,21 @@ const MapComponentBase = ({ osMapsApiKey, basePath, children }: MapComponentProp
135
135
  return true; // stop iteration
136
136
  }
137
137
 
138
- // Direct feature (e.g. polygon) — only show popup for the sampling points
139
- // layer, not boundary or AOI layers which may also have name properties.
138
+ // Direct feature (e.g. polygon or MVT tile feature) — only show popup
139
+ // for the sampling points layer, not boundary or AOI layers.
140
140
  if (layerName !== LAYER_NAMES.SamplingPoints) {
141
141
  return;
142
142
  }
143
143
 
144
- const name = feature.get('name');
145
- const notation = feature.get('notation');
144
+ const coordinate = event.coordinate;
145
+ const direction = getPopupPositionClass(coordinate, newMap);
146
146
 
147
- if (name || notation) {
148
- const coordinate = event.coordinate;
149
- const direction = getPopupPositionClass(coordinate, newMap);
150
-
151
- setPopupFeatures([feature as Feature]);
152
- setPopupCoordinate(coordinate);
153
- setPopupPositionClass(direction);
154
- overlay.setPosition(event.coordinate);
147
+ setPopupFeatures([feature as Feature]);
148
+ setPopupCoordinate(coordinate);
149
+ setPopupPositionClass(direction);
150
+ overlay.setPosition(event.coordinate);
155
151
 
156
- return true; // stop iteration
157
- }
152
+ return true; // stop iteration
158
153
  });
159
154
  });
160
155
 
package/src/map/Popup.tsx CHANGED
@@ -18,7 +18,7 @@ export const Popup = ({
18
18
  arrowClasses = 'bottom-left',
19
19
  }: PopupProps) => {
20
20
  if (!clickedCoord) {
21
- return null;
21
+ return <></>;
22
22
  }
23
23
 
24
24
  return (
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useId, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
4
4
 
5
5
  import type Map from 'ol/Map';
6
6
 
7
7
  import { groupResults } from './groupResults';
8
8
  import type { GeocoderResult } from './types';
9
+ import { useDebounce } from '../../hooks/useDebounce';
9
10
 
10
11
  // SVG Icons matching Mapbox GL Geocoder style
11
12
  const SearchIcon = () => (
@@ -69,6 +70,9 @@ const LoadingIcon = () => (
69
70
  </svg>
70
71
  );
71
72
 
73
+ // Typeahead defaults
74
+ const DEFAULT_TYPEAHEAD_DELAY_MS = 500;
75
+
72
76
  // Animation defaults - can be overridden via props
73
77
  const DEFAULT_TARGET_ZOOM = 14;
74
78
  const DEFAULT_FLY_OUT_ZOOM_MIN = 6;
@@ -97,8 +101,10 @@ export type GeocoderProps = {
97
101
  fitPadding?: number;
98
102
  /** Debug callback for logging results and selections */
99
103
  onDebug?: (event: { type: 'results' | 'select' | 'clear'; data?: unknown }) => void;
100
- // To enable auto-search on typing (debounced), import useDebounce from '../../hooks/useDebounce'
101
- // and add: debounceDelay?: number; prop, then use debouncedQuery in a useEffect
104
+ /** Enable typeahead search while typing */
105
+ enableTypeahead?: boolean;
106
+ /** Debounce delay in ms for typeahead search (default: 500) */
107
+ typeaheadDelay?: number;
102
108
  };
103
109
 
104
110
  export const Geocoder = ({
@@ -114,6 +120,8 @@ export const Geocoder = ({
114
120
  flyToDuration = DEFAULT_FLY_TO_DURATION,
115
121
  fitPadding = DEFAULT_FIT_PADDING,
116
122
  onDebug,
123
+ enableTypeahead = false,
124
+ typeaheadDelay = DEFAULT_TYPEAHEAD_DELAY_MS,
117
125
  }: GeocoderProps) => {
118
126
  const generatedId = useId();
119
127
  const id = providedId ?? generatedId;
@@ -129,6 +137,50 @@ export const Geocoder = ({
129
137
  const [status, setStatus] = useState('');
130
138
  const [isSearching, setIsSearching] = useState(false);
131
139
 
140
+ const debouncedQuery = useDebounce(query, typeaheadDelay);
141
+
142
+ const performSearch = useCallback(
143
+ async (queryStr: string) => {
144
+ if (queryStr.length < minChars) {
145
+ setStatus(`Enter at least ${minChars} characters`);
146
+
147
+ return;
148
+ }
149
+
150
+ setIsSearching(true);
151
+ setStatus('Searching...');
152
+
153
+ try {
154
+ const data = await search(queryStr);
155
+
156
+ setResults(data);
157
+ setSelectedId(null);
158
+ setOpen(true);
159
+ setActiveIndex(-1);
160
+ setStatus(data.length ? `${data.length} results available` : 'No results found');
161
+
162
+ // Debug callback for results
163
+ onDebug?.({
164
+ type: 'results',
165
+ data: { query: queryStr, results: data, count: data.length },
166
+ });
167
+ } catch (error) {
168
+ console.error('Search error:', error);
169
+ setStatus('There was an error reaching the server');
170
+ setResults([]);
171
+ } finally {
172
+ setIsSearching(false);
173
+ }
174
+ },
175
+ [minChars, search, onDebug],
176
+ );
177
+
178
+ useEffect(() => {
179
+ if (enableTypeahead && debouncedQuery.length >= minChars) {
180
+ performSearch(debouncedQuery);
181
+ }
182
+ }, [debouncedQuery, enableTypeahead, minChars, performSearch]);
183
+
132
184
  // Scroll the active result into view when navigating with arrow keys
133
185
  useEffect(() => {
134
186
  if (activeIndex >= 0) {
@@ -138,36 +190,6 @@ export const Geocoder = ({
138
190
  }
139
191
  }, [activeIndex, listboxId]);
140
192
 
141
- const performSearch = async () => {
142
- if (query.length < minChars) {
143
- setStatus(`Enter at least ${minChars} characters`);
144
-
145
- return;
146
- }
147
-
148
- setIsSearching(true);
149
- setStatus('Searching...');
150
-
151
- try {
152
- const data = await search(query);
153
-
154
- setResults(data);
155
- setSelectedId(null);
156
- setOpen(true);
157
- setActiveIndex(-1);
158
- setStatus(data.length ? `${data.length} results available` : 'No results found');
159
-
160
- // Debug callback for results
161
- onDebug?.({ type: 'results', data: { query, results: data, count: data.length } });
162
- } catch (error) {
163
- console.error('Search error:', error);
164
- setStatus('There was an error reaching the server');
165
- setResults([]);
166
- } finally {
167
- setIsSearching(false);
168
- }
169
- };
170
-
171
193
  const selectResult = (result: GeocoderResult) => {
172
194
  const view = map.getView();
173
195
  const currentZoom = view.getZoom() ?? 10;
@@ -257,7 +279,10 @@ export const Geocoder = ({
257
279
  </label>
258
280
 
259
281
  {/* Mapbox-style geocoder container */}
260
- <div className="relative bg-white rounded shadow-lg transition-all duration-200">
282
+ <div
283
+ className="relative bg-white rounded shadow-lg transition-all duration-200
284
+ focus-within:ring-2 focus-within:ring-focus"
285
+ >
261
286
  <SearchIcon />
262
287
 
263
288
  <input
@@ -298,7 +323,7 @@ export const Geocoder = ({
298
323
  if (activeResult && open) {
299
324
  selectResult(activeResult);
300
325
  } else {
301
- performSearch();
326
+ performSearch(query);
302
327
  }
303
328
  }
304
329
 
@@ -335,7 +360,7 @@ export const Geocoder = ({
335
360
  );
336
361
  }
337
362
 
338
- return null;
363
+ return <></>;
339
364
  })()}
340
365
  </div>
341
366
  </div>
@@ -392,7 +417,7 @@ export const Geocoder = ({
392
417
  );
393
418
  }
394
419
 
395
- return null;
420
+ return <></>;
396
421
  })()
397
422
  )}
398
423
  </div>
@@ -0,0 +1,186 @@
1
+ import { transform, transformExtent } from 'ol/proj';
2
+ import { describe, expect, it, vi, afterEach } from 'vitest';
3
+
4
+ import {
5
+ mapOsOpenNamesFeaturesToGeocoderResults,
6
+ mapOsOpenNamesFeatureToGeocoderResult,
7
+ } from './osOpenNamesSearch';
8
+
9
+ vi.mock('ol/proj', () => ({
10
+ transform: vi.fn((coords) => coords),
11
+ transformExtent: vi.fn((extent) => extent),
12
+ }));
13
+
14
+ const createPointFeature = (overrides: Record<string, unknown> = {}) =>
15
+ ({
16
+ type: 'Feature',
17
+ properties: {
18
+ id: 'place-1',
19
+ address: '10 Downing Street',
20
+ type: 'populatedPlace',
21
+ },
22
+ geometry: {
23
+ type: 'Point',
24
+ coordinates: [-0.1276, 51.5034],
25
+ },
26
+ ...overrides,
27
+ }) as never;
28
+
29
+ const createPolygonFeature = (overrides: Record<string, unknown> = {}) =>
30
+ ({
31
+ type: 'Feature',
32
+ properties: {
33
+ id: 'area-1',
34
+ name: 'Westminster',
35
+ type: 'city',
36
+ },
37
+ geometry: {
38
+ type: 'Polygon',
39
+ coordinates: [],
40
+ },
41
+ bbox: [-0.15, 51.49, -0.12, 51.52],
42
+ ...overrides,
43
+ }) as never;
44
+
45
+ describe('mapOsOpenNamesFeatureToGeocoderResult', () => {
46
+ afterEach(() => {
47
+ vi.clearAllMocks();
48
+ });
49
+
50
+ it('maps a Point feature with string address', () => {
51
+ const result = mapOsOpenNamesFeatureToGeocoderResult(createPointFeature(), 0);
52
+
53
+ expect(result[0]).toEqual({
54
+ id: 'place-1',
55
+ label: '10 Downing Street',
56
+ group: 'populatedPlace',
57
+ zoom: expect.any(Number),
58
+ center: [-0.1276, 51.5034],
59
+ });
60
+
61
+ expect(transform).toHaveBeenCalledWith(
62
+ [-0.1276, 51.5034],
63
+ expect.anything(),
64
+ expect.anything(),
65
+ );
66
+ });
67
+
68
+ it('builds the label from address object fields', () => {
69
+ const feature = createPointFeature({
70
+ properties: {
71
+ id: 'place-2',
72
+ address: {
73
+ name: 'Baker Street',
74
+ town: 'London',
75
+ country: 'England',
76
+ },
77
+ type: 'street',
78
+ },
79
+ });
80
+
81
+ const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 1);
82
+
83
+ expect(result[0]?.label).toBe('Baker Street, London, England');
84
+ });
85
+
86
+ it('uses bbox to populate extent and center', () => {
87
+ const result = mapOsOpenNamesFeatureToGeocoderResult(createPolygonFeature(), 0);
88
+
89
+ expect(result[0]).toEqual({
90
+ id: 'area-1',
91
+ label: 'Westminster',
92
+ group: 'city',
93
+ zoom: expect.any(Number),
94
+ extent: [-0.15, 51.49, -0.12, 51.52],
95
+ center: [-0.135, 51.505],
96
+ });
97
+
98
+ expect(transformExtent).toHaveBeenCalledWith(
99
+ [-0.15, 51.49, -0.12, 51.52],
100
+ expect.anything(),
101
+ expect.anything(),
102
+ );
103
+
104
+ expect(transform).toHaveBeenCalledWith([-0.135, 51.505], expect.anything(), expect.anything());
105
+ });
106
+
107
+ it('falls back to localType when type is missing', () => {
108
+ const feature = createPointFeature({
109
+ properties: {
110
+ id: 'place-3',
111
+ name: 'Somewhere',
112
+ localType: 'Village',
113
+ },
114
+ });
115
+
116
+ const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 0);
117
+
118
+ expect(result[0]?.group).toBe('Village');
119
+ });
120
+
121
+ it('falls back to generated id and label when missing', () => {
122
+ const feature = createPointFeature({
123
+ properties: {},
124
+ });
125
+
126
+ const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 7);
127
+
128
+ expect(result[0]?.id).toBe('result-7');
129
+ expect(result[0]?.label).toBe('result-7');
130
+ });
131
+
132
+ it('returns null for non-Point geometry without bbox', () => {
133
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
134
+
135
+ const feature = createPolygonFeature({
136
+ bbox: undefined,
137
+ });
138
+
139
+ const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 0);
140
+
141
+ expect(result[0]).toBeUndefined();
142
+ expect(errorSpy).toHaveBeenCalledWith('Feature has no bbox and geometry is not a Point');
143
+ });
144
+
145
+ it('returns null for invalid Point coordinates', () => {
146
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
147
+
148
+ const feature = createPointFeature({
149
+ geometry: {
150
+ type: 'Point',
151
+ coordinates: ['bad', 51.5],
152
+ },
153
+ });
154
+
155
+ const result = mapOsOpenNamesFeatureToGeocoderResult(feature, 0);
156
+
157
+ expect(result[0]).toBeUndefined();
158
+ expect(errorSpy).toHaveBeenCalledWith('Invalid coordinates: lon or lat is not a number');
159
+ });
160
+ });
161
+
162
+ describe('mapOsOpenNamesFeaturesToGeocoderResults', () => {
163
+ afterEach(() => {
164
+ vi.clearAllMocks();
165
+ });
166
+
167
+ it('maps features and filters out null results', () => {
168
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
169
+
170
+ const results = mapOsOpenNamesFeaturesToGeocoderResults([
171
+ createPointFeature({
172
+ properties: { id: 'valid-1', address: 'Valid place', type: 'street' },
173
+ }),
174
+ createPolygonFeature({
175
+ bbox: undefined,
176
+ }),
177
+ createPointFeature({
178
+ properties: { id: 'valid-2', address: 'Another place', type: 'street' },
179
+ }),
180
+ ]);
181
+
182
+ expect(results).toHaveLength(2);
183
+ expect(results.map((result) => result.id)).toEqual(['valid-1', 'valid-2']);
184
+ expect(errorSpy).toHaveBeenCalledTimes(1);
185
+ });
186
+ });
@@ -1,4 +1,4 @@
1
- import type { FeatureCollection } from 'geojson';
1
+ import type { Feature, FeatureCollection } from 'geojson';
2
2
  import { transform, transformExtent } from 'ol/proj';
3
3
 
4
4
  import type { GeocoderResult } from './geocoder/types';
@@ -26,6 +26,82 @@ const ZOOM_BY_TYPE: Record<string, number> = {
26
26
  'Postcode Unit': 18,
27
27
  };
28
28
 
29
+ export const mapOsOpenNamesFeatureToGeocoderResult = (
30
+ feature: Feature,
31
+ index: number,
32
+ ): [] | [GeocoderResult] => {
33
+ const properties = feature.properties ?? {};
34
+
35
+ let label: string;
36
+
37
+ if (properties.address && typeof properties.address === 'object') {
38
+ const addr = properties.address as { name?: string; town?: string; country?: string };
39
+
40
+ label = [addr.name, addr.town, addr.country].filter(Boolean).join(', ');
41
+ } else {
42
+ label = properties.address ?? properties.name ?? `result-${index}`;
43
+ }
44
+
45
+ const result: GeocoderResult = {
46
+ id: properties.id ?? `result-${index}`,
47
+ label,
48
+ group: properties.type ?? properties.localType,
49
+ zoom: ZOOM_BY_TYPE[properties.type] ?? ZOOM_BY_TYPE[properties.localType] ?? 14,
50
+ };
51
+
52
+ if (feature.bbox && Array.isArray(feature.bbox) && feature.bbox.length === 4) {
53
+ const [minLon, minLat, maxLon, maxLat] = feature.bbox;
54
+
55
+ result.extent = transformExtent(feature.bbox, EPSG_4326, EPSG_3857) as [
56
+ number,
57
+ number,
58
+ number,
59
+ number,
60
+ ];
61
+
62
+ const [x, y] = transform([(minLon + maxLon) / 2, (minLat + maxLat) / 2], EPSG_4326, EPSG_3857);
63
+
64
+ if (typeof x !== 'number' || typeof y !== 'number') {
65
+ console.error('Transform failed: x or y is not a number');
66
+
67
+ return [];
68
+ }
69
+
70
+ result.center = [x, y];
71
+
72
+ return [result];
73
+ }
74
+
75
+ if (feature.geometry.type === 'Point') {
76
+ const [lon, lat] = feature.geometry.coordinates;
77
+
78
+ if (typeof lon !== 'number' || typeof lat !== 'number') {
79
+ console.error('Invalid coordinates: lon or lat is not a number');
80
+
81
+ return [];
82
+ }
83
+
84
+ const [x, y] = transform([lon, lat], EPSG_4326, EPSG_3857);
85
+
86
+ if (typeof x !== 'number' || typeof y !== 'number') {
87
+ console.error('Transform failed: x or y is not a number');
88
+
89
+ return [];
90
+ }
91
+
92
+ result.center = [x, y];
93
+
94
+ return [result];
95
+ }
96
+
97
+ console.error('Feature has no bbox and geometry is not a Point');
98
+
99
+ return [];
100
+ };
101
+
102
+ export const mapOsOpenNamesFeaturesToGeocoderResults = (features: Feature[]): GeocoderResult[] =>
103
+ features.flatMap((feature, index) => mapOsOpenNamesFeatureToGeocoderResult(feature, index));
104
+
29
105
  /**
30
106
  * Creates a search function for OS Open Names API that returns GeocoderResult[].
31
107
  *
@@ -61,76 +137,6 @@ export const createOsOpenNamesSearch = (options: OsOpenNamesSearchOptions) => {
61
137
  return [];
62
138
  }
63
139
 
64
- return features
65
- .map((feature, index): GeocoderResult | null => {
66
- const properties = feature.properties ?? {};
67
-
68
- // Handle address which can be an object or string
69
- let label: string;
70
-
71
- if (properties.address && typeof properties.address === 'object') {
72
- const addr = properties.address as { name?: string; town?: string; country?: string };
73
-
74
- label = [addr.name, addr.town, addr.country].filter(Boolean).join(', ');
75
- } else {
76
- label = properties.address ?? properties.name ?? `result-${index}`;
77
- }
78
-
79
- const result: GeocoderResult = {
80
- id: properties.id ?? `result-${index}`,
81
- label,
82
- group: properties.type ?? properties.localType,
83
- // Assign zoom level based on type, fallback to 14
84
- zoom: ZOOM_BY_TYPE[properties.type] ?? ZOOM_BY_TYPE[properties.localType] ?? 14,
85
- };
86
-
87
- // Prefer bbox — works for all geometry types (Point, Polygon, MultiPolygon)
88
- if (feature.bbox && Array.isArray(feature.bbox) && feature.bbox.length === 4) {
89
- const [minLon, minLat, maxLon, maxLat] = feature.bbox;
90
-
91
- result.extent = transformExtent(feature.bbox, EPSG_4326, EPSG_3857) as [
92
- number,
93
- number,
94
- number,
95
- number,
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;
130
- }
131
-
132
- return result;
133
- })
134
- .filter((result): result is GeocoderResult => result !== null);
140
+ return mapOsOpenNamesFeaturesToGeocoderResults(features);
135
141
  };
136
142
  };
package/src/utils/http.ts CHANGED
@@ -48,7 +48,7 @@ const encodeBase64 = (input: string): string => {
48
48
  throw new Error('Base64 encoding not available in this environment');
49
49
  };
50
50
 
51
- const getAuthorizationHeader = (): { Authorization: string } | Record<string, never> => {
51
+ const getAuthorizationHeader = (): { Authorization: string } => {
52
52
  const apiHasAuth = getNodeEnvVar('API_HAS_AUTH');
53
53
  const apiUsername = getNodeEnvVar('API_USERNAME');
54
54
  const apiPassword = getNodeEnvVar('API_PASSWORD');
@@ -61,7 +61,7 @@ const getAuthorizationHeader = (): { Authorization: string } | Record<string, ne
61
61
  };
62
62
  }
63
63
 
64
- return {};
64
+ return { Authorization: '' };
65
65
  };
66
66
 
67
67
  // common fetch wrapper to provide auth headers if necessary