@tpzdsp/next-toolkit 1.7.0 → 1.9.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import z from 'zod/v4';
4
4
 
5
- import { HttpStatus, HttpStatusText } from '../http';
5
+ import { HttpStatus, HttpStatusText } from '../http/constants';
6
6
 
7
7
  /**
8
8
  * Schema defining the JSON shape of error responses returned by a server. (The proxy is where
@@ -53,7 +53,13 @@ export class ApiError extends Error implements z.output<typeof ApiErrorSchema> {
53
53
  this.name = 'ApiError';
54
54
  this.status = status;
55
55
  this.details = details ?? null;
56
- this.digest = crypto.randomUUID();
56
+ // Crypto is a NodeJS and browser global; use it if available, but fallback to a pseudo-random string otherwise.
57
+ // This is due to Storybook not providing the `crypto` global in some environments (e.g., Jest).
58
+ this.digest =
59
+ typeof crypto?.randomUUID === 'function'
60
+ ? crypto.randomUUID()
61
+ : // eslint-disable-next-line sonarjs/pseudo-random
62
+ Math.random().toString(36).slice(2);
57
63
  this.rehydrated = options?.rehydrated ?? false;
58
64
 
59
65
  // Maintains proper stack trace for where our error was thrown (only available on V8)
@@ -99,6 +99,8 @@ export const Header = {
99
99
  CsvHeader: 'CSV-Header',
100
100
  XTotalItems: 'X-Total-Items',
101
101
  XRequestId: 'X-Request-ID',
102
+ ApiVersion: 'API-Version',
103
+ CacheControl: 'Cache-Control',
102
104
  } as const;
103
105
 
104
106
  /**
@@ -1,11 +1,11 @@
1
1
  /* eslint-disable no-restricted-syntax */
2
+ import { createFocusTrap } from 'focus-trap';
3
+ import type { FocusTrap } from 'focus-trap';
2
4
  import { Map } from 'ol';
3
5
  import { Control } from 'ol/control';
4
6
  import type { Options as ControlOptions } from 'ol/control/Control';
5
7
  import BaseLayer from 'ol/layer/Base';
6
8
 
7
- import { KeyboardKeys } from '../utils';
8
-
9
9
  const TIMEOUT = 300; // Match CSS transition duration
10
10
  const ARIA_LABEL = 'aria-label';
11
11
 
@@ -16,7 +16,8 @@ export class LayerSwitcherControl extends Control {
16
16
  map!: Map;
17
17
  panel!: HTMLElement;
18
18
  liveRegion!: HTMLElement;
19
- isCollapsed = true;
19
+ isOpen = false;
20
+ private focusTrap: FocusTrap | null = null;
20
21
 
21
22
  constructor(layers: BaseLayer[], options?: ControlOptions) {
22
23
  const button = document.createElement('button');
@@ -70,9 +71,6 @@ export class LayerSwitcherControl extends Control {
70
71
  this.panel.setAttribute('aria-modal', 'true');
71
72
  this.panel.setAttribute(ARIA_LABEL, 'Basemap switcher');
72
73
 
73
- // Add a keydown listener to the panel for Escape key
74
- this.panel.addEventListener('keydown', this.handlePanelKeyDown);
75
-
76
74
  // Create the header for the close button
77
75
  const header = document.createElement('div');
78
76
 
@@ -87,7 +85,6 @@ export class LayerSwitcherControl extends Control {
87
85
  closeBtn.type = 'button';
88
86
  closeBtn.addEventListener('click', () => {
89
87
  this.toggleLayerSwitcher();
90
- this.focusToggleButton();
91
88
  });
92
89
  header.appendChild(closeBtn);
93
90
 
@@ -124,6 +121,29 @@ export class LayerSwitcherControl extends Control {
124
121
  this.panel.appendChild(header);
125
122
  this.panel.appendChild(content);
126
123
 
124
+ // Create focus trap once - will be activated/deactivated as needed
125
+ this.focusTrap = createFocusTrap(this.panel, {
126
+ clickOutsideDeactivates: (event) => {
127
+ // if the user happens to click on the open button again we let the button handle the click event, not the focus trap
128
+ if (button.contains(event.target as Node)) {
129
+ return true;
130
+ }
131
+
132
+ this.toggleLayerSwitcher();
133
+
134
+ return false;
135
+ },
136
+ escapeDeactivates: () => {
137
+ this.toggleLayerSwitcher();
138
+
139
+ return false;
140
+ },
141
+ returnFocusOnDeactivate: true,
142
+ fallbackFocus: () => {
143
+ return this.panel;
144
+ },
145
+ });
146
+
127
147
  button.addEventListener('click', this.toggleLayerSwitcher, false);
128
148
  }
129
149
 
@@ -142,22 +162,6 @@ export class LayerSwitcherControl extends Control {
142
162
  .find((layer: BaseLayer) => layer.get('name') === layerName);
143
163
  }
144
164
 
145
- private focusFirstButton() {
146
- const firstBtn = this.panel.querySelector(
147
- 'button:not(.ol-layer-switcher-close)',
148
- ) as HTMLButtonElement | null;
149
-
150
- firstBtn?.focus();
151
- }
152
-
153
- private focusToggleButton() {
154
- const toggleBtn = this.element.querySelector(
155
- 'button.ol-layer-switcher-toggle',
156
- ) as HTMLButtonElement | null;
157
-
158
- toggleBtn?.focus();
159
- }
160
-
161
165
  private announceBasemapChange(layerName: string) {
162
166
  if (this.liveRegion) {
163
167
  this.liveRegion.textContent = `Basemap changed to ${layerName}`;
@@ -181,59 +185,33 @@ export class LayerSwitcherControl extends Control {
181
185
 
182
186
  // Arrow function: 'this' is always bound to the class instance
183
187
  toggleLayerSwitcher = () => {
184
- if (this.isCollapsed) {
188
+ if (!this.isOpen) {
185
189
  this.element.appendChild(this.panel);
190
+
186
191
  requestAnimationFrame(() => {
187
192
  this.panel.classList.add('open'); // Ensure animation works after adding to DOM
188
193
 
189
- // Focus the first basemap button (skip the close button)
190
- this.focusFirstButton();
194
+ // Activate the existing focus trap
195
+ this.focusTrap?.activate();
191
196
  });
192
197
  } else {
193
198
  this.panel.classList.remove('open');
199
+
200
+ // Deactivate focus trap but keep the instance
201
+ this.focusTrap?.deactivate();
202
+
194
203
  setTimeout(() => {
195
204
  this.element.removeChild(this.panel);
196
205
  }, TIMEOUT); // Matches CSS transition time to prevent flickering
197
206
  }
198
207
 
199
- this.isCollapsed = !this.isCollapsed;
208
+ this.isOpen = !this.isOpen;
200
209
 
201
210
  // Update aria-expanded on the toggle button
202
211
  const toggleBtn = this.element.querySelector('button.ol-layer-switcher-toggle');
203
212
 
204
213
  if (toggleBtn) {
205
- toggleBtn.setAttribute('aria-expanded', (!this.isCollapsed).toString());
206
- }
207
- };
208
-
209
- // Arrow function for panel keydown
210
- handlePanelKeyDown = (event: KeyboardEvent) => {
211
- // Focus trap: cycle focus within the panel
212
- if (event.key === KeyboardKeys.Tab) {
213
- const focusable = Array.from(this.panel.querySelectorAll('button')) as HTMLButtonElement[];
214
-
215
- if (focusable.length === 0) {
216
- return;
217
- }
218
-
219
- const first = focusable[0];
220
- const last = focusable[focusable.length - 1];
221
-
222
- if (!event.shiftKey && document.activeElement === last) {
223
- event.preventDefault();
224
- first.focus();
225
- } else if (event.shiftKey && document.activeElement === first) {
226
- event.preventDefault();
227
- last.focus();
228
- }
229
- }
230
-
231
- // Escape closes the panel and returns focus to the toggle button
232
- if (event.key === KeyboardKeys.Escape) {
233
- this.toggleLayerSwitcher();
234
-
235
- // Focus back on the basemap switcher button
236
- this.focusToggleButton();
214
+ toggleBtn.setAttribute('aria-expanded', this.isOpen.toString());
237
215
  }
238
216
  };
239
217
 
@@ -276,4 +254,14 @@ export class LayerSwitcherControl extends Control {
276
254
  btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
277
255
  });
278
256
  };
257
+
258
+ // Cleanup method - call this when removing the control
259
+ destroy() {
260
+ // Deactivate and destroy focus trap
261
+ if (this.focusTrap) {
262
+ this.focusTrap.deactivate();
263
+ }
264
+
265
+ this.focusTrap = null;
266
+ }
279
267
  }
@@ -75,7 +75,7 @@ export const MapComponent = ({
75
75
  }
76
76
 
77
77
  // Initialise map's basemap layers.
78
- const layers = initializeBasemapLayers();
78
+ const layers = initializeBasemapLayers(basePath);
79
79
 
80
80
  const target = mapRef.current;
81
81
 
@@ -8,7 +8,7 @@ import SatelliteMapTilerImage from './images/basemaps/satellite-map-tiler.png';
8
8
  import SatelliteImage from './images/basemaps/satellite.png';
9
9
  import StreetsImage from './images/basemaps/streets.png';
10
10
 
11
- export const initializeBasemapLayers = () => {
11
+ export const initializeBasemapLayers = (baseUrl: string) => {
12
12
  const osmLayer = new TileLayer({
13
13
  preload: Infinity,
14
14
  source: new OSM(),
@@ -24,7 +24,7 @@ export const initializeBasemapLayers = () => {
24
24
  const osMapsLight = new TileLayer({
25
25
  // attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
26
26
  source: new XYZ({
27
- url: '/water-quality-archive/api/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png',
27
+ url: `${baseUrl}/api/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png`,
28
28
  }),
29
29
  visible: false,
30
30
  });
@@ -36,7 +36,7 @@ export const initializeBasemapLayers = () => {
36
36
  const osMapsOutdoor = new TileLayer({
37
37
  // attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
38
38
  source: new XYZ({
39
- url: '/water-quality-archive/api/maps/raster/v1/zxy/Outdoor_3857/{z}/{x}/{y}.png',
39
+ url: `${baseUrl}/api/maps/raster/v1/zxy/Outdoor_3857/{z}/{x}/{y}.png`,
40
40
  }),
41
41
  visible: false,
42
42
  });
@@ -50,7 +50,7 @@ export const initializeBasemapLayers = () => {
50
50
  const osMapsRoad = new TileLayer({
51
51
  // attributions: [`© Crown Copyright and Database Rights [insert year of creation] OS AC0000807064`],
52
52
  source: new XYZ({
53
- url: '/water-quality-archive/api/maps/raster/v1/zxy/Road_3857/{z}/{x}/{y}.png',
53
+ url: `${baseUrl}/api/maps/raster/v1/zxy/Road_3857/{z}/{x}/{y}.png`,
54
54
  }),
55
55
  visible: false,
56
56
  });
@@ -0,0 +1,6 @@
1
+ import { format, type DateArg } from 'date-fns';
2
+
3
+ export const DEFAULT_DATE_FORMAT = 'yyyy-MM-dd';
4
+
5
+ export const formatDate = (date: DateArg<Date>, pattern = DEFAULT_DATE_FORMAT) =>
6
+ format(date instanceof Date ? date : new Date(date), pattern);
@@ -1,3 +1,4 @@
1
1
  export * from './utils';
2
2
  export * from './auth';
3
3
  export * from './constants';
4
+ export * from './date';
@@ -1,4 +1,4 @@
1
- import { decode } from 'jsonwebtoken';
1
+ import { verify } from 'jsonwebtoken';
2
2
  import { twMerge } from 'tailwind-merge';
3
3
 
4
4
  import type { Credentials, DecodedJWT } from '../types/auth';
@@ -9,10 +9,19 @@ export const decodeAuthToken = (token: string): Credentials | null => {
9
9
  }
10
10
 
11
11
  try {
12
- const user = decode(token) as unknown as DecodedJWT;
12
+ // eslint-disable-next-line no-undef
13
+ const secret = process.env.JWT_SECRET;
14
+
15
+ if (!secret) {
16
+ throw new Error('JWT secret not set');
17
+ }
18
+
19
+ const user = verify(token, secret) as DecodedJWT;
13
20
 
14
21
  return { token, user };
15
- } catch {
22
+ } catch (error) {
23
+ console.error('Error decoding auth token:', error);
24
+
16
25
  return null;
17
26
  }
18
27
  };