@tpzdsp/next-toolkit 1.15.3 → 2.0.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.
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.3",
3
+ "version": "2.0.1",
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) {
@@ -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,73 +1,17 @@
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
+ import { ImSpinner2 } from 'react-icons/im';
7
+ import { IoClose, IoSearch } from 'react-icons/io5';
6
8
 
7
9
  import { groupResults } from './groupResults';
8
10
  import type { GeocoderResult } from './types';
11
+ import { useDebounce } from '../../hooks/useDebounce';
9
12
 
10
- // SVG Icons matching Mapbox GL Geocoder style
11
- const SearchIcon = () => (
12
- <svg
13
- className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
14
- width="20"
15
- height="20"
16
- viewBox="0 0 18 18"
17
- fill="none"
18
- xmlns="http://www.w3.org/2000/svg"
19
- >
20
- <path
21
- d="M7.5 13.5C10.8137 13.5 13.5 10.8137 13.5 7.5C13.5 4.18629 10.8137 1.5 7.5 1.5C4.18629 1.5 1.5 4.18629 1.5 7.5C1.5 10.8137 4.18629 13.5 7.5 13.5Z"
22
- stroke="#757575"
23
- strokeWidth="1.5"
24
- strokeLinecap="round"
25
- strokeLinejoin="round"
26
- />
27
-
28
- <path
29
- d="M16.5 16.5L11.625 11.625"
30
- stroke="#757575"
31
- strokeWidth="1.5"
32
- strokeLinecap="round"
33
- strokeLinejoin="round"
34
- />
35
- </svg>
36
- );
37
-
38
- const ClearIcon = () => (
39
- <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
40
- <path
41
- d="M15 5L5 15M5 5L15 15"
42
- stroke="#757575"
43
- strokeWidth="2"
44
- strokeLinecap="round"
45
- strokeLinejoin="round"
46
- />
47
- </svg>
48
- );
49
-
50
- const LoadingIcon = () => (
51
- <svg
52
- className="animate-spin"
53
- width="26"
54
- height="26"
55
- viewBox="0 0 26 26"
56
- fill="none"
57
- xmlns="http://www.w3.org/2000/svg"
58
- >
59
- <circle
60
- cx="13"
61
- cy="13"
62
- r="10"
63
- stroke="#757575"
64
- strokeWidth="3"
65
- strokeLinecap="round"
66
- strokeDasharray="60"
67
- strokeDashoffset="40"
68
- />
69
- </svg>
70
- );
13
+ // Typeahead defaults
14
+ const DEFAULT_TYPEAHEAD_DELAY_MS = 500;
71
15
 
72
16
  // Animation defaults - can be overridden via props
73
17
  const DEFAULT_TARGET_ZOOM = 14;
@@ -97,8 +41,10 @@ export type GeocoderProps = {
97
41
  fitPadding?: number;
98
42
  /** Debug callback for logging results and selections */
99
43
  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
44
+ /** Enable typeahead search while typing */
45
+ enableTypeahead?: boolean;
46
+ /** Debounce delay in ms for typeahead search (default: 500) */
47
+ typeaheadDelay?: number;
102
48
  };
103
49
 
104
50
  export const Geocoder = ({
@@ -114,6 +60,8 @@ export const Geocoder = ({
114
60
  flyToDuration = DEFAULT_FLY_TO_DURATION,
115
61
  fitPadding = DEFAULT_FIT_PADDING,
116
62
  onDebug,
63
+ enableTypeahead = false,
64
+ typeaheadDelay = DEFAULT_TYPEAHEAD_DELAY_MS,
117
65
  }: GeocoderProps) => {
118
66
  const generatedId = useId();
119
67
  const id = providedId ?? generatedId;
@@ -129,6 +77,50 @@ export const Geocoder = ({
129
77
  const [status, setStatus] = useState('');
130
78
  const [isSearching, setIsSearching] = useState(false);
131
79
 
80
+ const debouncedQuery = useDebounce(query, typeaheadDelay);
81
+
82
+ const performSearch = useCallback(
83
+ async (queryStr: string) => {
84
+ if (queryStr.length < minChars) {
85
+ setStatus(`Enter at least ${minChars} characters`);
86
+
87
+ return;
88
+ }
89
+
90
+ setIsSearching(true);
91
+ setStatus('Searching...');
92
+
93
+ try {
94
+ const data = await search(queryStr);
95
+
96
+ setResults(data);
97
+ setSelectedId(null);
98
+ setOpen(true);
99
+ setActiveIndex(-1);
100
+ setStatus(data.length ? `${data.length} results available` : 'No results found');
101
+
102
+ // Debug callback for results
103
+ onDebug?.({
104
+ type: 'results',
105
+ data: { query: queryStr, results: data, count: data.length },
106
+ });
107
+ } catch (error) {
108
+ console.error('Search error:', error);
109
+ setStatus('There was an error reaching the server');
110
+ setResults([]);
111
+ } finally {
112
+ setIsSearching(false);
113
+ }
114
+ },
115
+ [minChars, search, onDebug],
116
+ );
117
+
118
+ useEffect(() => {
119
+ if (enableTypeahead && debouncedQuery.length >= minChars) {
120
+ performSearch(debouncedQuery);
121
+ }
122
+ }, [debouncedQuery, enableTypeahead, minChars, performSearch]);
123
+
132
124
  // Scroll the active result into view when navigating with arrow keys
133
125
  useEffect(() => {
134
126
  if (activeIndex >= 0) {
@@ -138,36 +130,6 @@ export const Geocoder = ({
138
130
  }
139
131
  }, [activeIndex, listboxId]);
140
132
 
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
133
  const selectResult = (result: GeocoderResult) => {
172
134
  const view = map.getView();
173
135
  const currentZoom = view.getZoom() ?? 10;
@@ -195,19 +157,11 @@ export const Geocoder = ({
195
157
  if (shouldZoomOut) {
196
158
  // Step 1: Zoom out, then combined pan + zoom in (like ol-geocoder)
197
159
  view.animate({ zoom: flyZoom, duration: zoomOutDuration }, () => {
198
- view.animate({
199
- center: result.center,
200
- zoom: finalZoom,
201
- duration: flyToDuration,
202
- });
160
+ view.animate({ center: result.center, zoom: finalZoom, duration: flyToDuration });
203
161
  });
204
162
  } else {
205
163
  // Combined pan + zoom in one smooth animation
206
- view.animate({
207
- center: result.center,
208
- zoom: finalZoom,
209
- duration: flyToDuration,
210
- });
164
+ view.animate({ center: result.center, zoom: finalZoom, duration: flyToDuration });
211
165
  }
212
166
  }
213
167
 
@@ -256,9 +210,14 @@ export const Geocoder = ({
256
210
  Search for a place
257
211
  </label>
258
212
 
259
- {/* Mapbox-style geocoder container */}
260
- <div className="relative bg-white rounded shadow-lg transition-all duration-200">
261
- <SearchIcon />
213
+ <div
214
+ className="relative bg-white rounded shadow-lg transition-all duration-200
215
+ focus-within:ring-2 focus-within:ring-focus"
216
+ >
217
+ <IoSearch
218
+ size={20}
219
+ className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400"
220
+ />
262
221
 
263
222
  <input
264
223
  ref={inputRef}
@@ -298,7 +257,7 @@ export const Geocoder = ({
298
257
  if (activeResult && open) {
299
258
  selectResult(activeResult);
300
259
  } else {
301
- performSearch();
260
+ performSearch(query);
302
261
  }
303
262
  }
304
263
 
@@ -311,13 +270,12 @@ export const Geocoder = ({
311
270
  placeholder:text-gray-500 focus:outline-none focus:ring-0"
312
271
  />
313
272
 
314
- {/* Clear or Loading button */}
315
273
  <div className="absolute right-2 top-1/2 -translate-y-1/2">
316
274
  {(() => {
317
275
  if (isSearching) {
318
276
  return (
319
277
  <div className="p-1">
320
- <LoadingIcon />
278
+ <ImSpinner2 size={22} className="animate-spin text-gray-500" />
321
279
  </div>
322
280
  );
323
281
  }
@@ -330,12 +288,12 @@ export const Geocoder = ({
330
288
  aria-label="Clear search"
331
289
  className="p-1 hover:bg-gray-100 rounded transition-colors"
332
290
  >
333
- <ClearIcon />
291
+ <IoClose size={20} className="text-gray-500" />
334
292
  </button>
335
293
  );
336
294
  }
337
295
 
338
- return null;
296
+ return <></>;
339
297
  })()}
340
298
  </div>
341
299
  </div>
@@ -344,7 +302,7 @@ export const Geocoder = ({
344
302
  {open ? (
345
303
  <div className="absolute z-1000 mt-1.5 w-full bg-white rounded shadow-lg overflow-hidden">
346
304
  {results.length > 0 ? (
347
- <div id={listboxId} role="listbox" className="max-h-60 overflow-auto">
305
+ <div id={listboxId} role="listbox" tabIndex={-1} className="max-h-60 overflow-auto">
348
306
  {Object.entries(grouped).map(([group, items]) => (
349
307
  <div key={group} role="presentation">
350
308
  <div
@@ -357,21 +315,22 @@ export const Geocoder = ({
357
315
  <div role="group">
358
316
  {items.map((item) => {
359
317
  flatIndex += 1;
360
- const isActive = flatIndex === activeIndex;
318
+ const itemIndex = flatIndex;
319
+ const isActive = itemIndex === activeIndex;
361
320
  const isSelected = item.id === selectedId;
362
321
 
363
322
  return (
364
323
  <div
365
- id={`${listboxId}-option-${flatIndex}`}
324
+ id={`${listboxId}-option-${itemIndex}`}
366
325
  key={item.id}
367
326
  role="option"
368
327
  aria-selected={isSelected}
369
328
  tabIndex={-1}
370
- className={`cursor-pointer px-3 py-2 text-sm text-gray-800
329
+ className={`scroll-mt-8 cursor-pointer px-3 py-2 text-sm text-gray-800
371
330
  transition-colors ${isSelected ? 'bg-blue-50 font-semibold' : ''}
372
331
  ${isActive && !isSelected ? 'bg-gray-100' : ''}
373
332
  ${!isSelected && !isActive ? 'hover:bg-gray-50' : ''}`}
374
- onMouseEnter={() => setActiveIndex(flatIndex)}
333
+ onMouseEnter={() => setActiveIndex(itemIndex)}
375
334
  onMouseDown={() => selectResult(item)}
376
335
  >
377
336
  <div className="truncate">{item.label}</div>
@@ -392,7 +351,7 @@ export const Geocoder = ({
392
351
  );
393
352
  }
394
353
 
395
- return null;
354
+ return <></>;
396
355
  })()
397
356
  )}
398
357
  </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