@defra/interactive-map 0.0.32-alpha → 0.0.33-alpha

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 (44) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/umd/im-core.js +1 -1
  4. package/dist/umd/index.js +1 -1
  5. package/docs/api/map-style-config.md +27 -3
  6. package/docs/assets/images/hero.png +0 -0
  7. package/package.json +1 -1
  8. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  9. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  10. package/plugins/beta/draw-ml/src/DrawInit.jsx +11 -0
  11. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +31 -25
  12. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +1 -1
  13. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +5 -4
  14. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +49 -21
  15. package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -1
  16. package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -1
  17. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  18. package/plugins/beta/draw-ol/src/core/OLDrawManager.js +3 -0
  19. package/plugins/beta/draw-ol/src/draw/DrawMode.js +57 -49
  20. package/plugins/beta/draw-ol/src/draw/drawInput.js +76 -32
  21. package/plugins/beta/draw-ol/src/edit/EditMode.js +4 -4
  22. package/plugins/beta/draw-ol/src/manifest.js +5 -1
  23. package/plugins/beta/draw-ol/src/reducer.js +1 -1
  24. package/plugins/beta/draw-ol/src/snap/snapEngine.js +75 -31
  25. package/plugins/beta/draw-ol/src/snap/snapGeometry.js +23 -46
  26. package/plugins/beta/draw-ol/src/snap/snapInteraction.js +30 -29
  27. package/plugins/beta/draw-ol/src/snap/snapManager.js +10 -1
  28. package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +9 -3
  29. package/plugins/beta/draw-ol/src/utils/olCoords.js +2 -2
  30. package/plugins/beta/draw-ol/src/utils/spatial.js +4 -8
  31. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  32. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  33. package/providers/beta/openlayers/dist/umd/index.js +1 -1
  34. package/providers/beta/openlayers/src/utils/tileLayers.js +29 -2
  35. package/providers/beta/openlayers/src/utils/tileLayers.test.js +53 -1
  36. package/src/App/components/MapButton/MapButton.jsx +14 -10
  37. package/src/App/components/MapButton/MapButton.module.scss +1 -1
  38. package/src/App/components/MapButton/MapButton.test.jsx +1 -1
  39. package/src/App/components/PopupMenu/PopupMenu.test.jsx +79 -0
  40. package/src/App/components/PopupMenu/usePopupMenu.js +61 -12
  41. package/src/types.js +12 -3
  42. package/src/utils/findNextTabStop.js +3 -3
  43. package/src/utils/findNextTabStop.test.js +25 -1
  44. package/webpack.dev.mjs +1 -0
@@ -1,2 +1,2 @@
1
1
  /*! For license information please see index.js.LICENSE.txt */
2
- !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("defra",[],t):"object"==typeof exports?exports.defra=t():(e.defra=e.defra||{},e.defra.openLayersProvider=t())}(this,()=>(()=>{"use strict";var e,t,r={},n={};function o(e){var t=n[e];if(void 0!==t)return t.exports;var i=n[e]={exports:{}};return r[e](i,i.exports,o),i.exports}o.m=r,o.d=(e,t)=>{for(var r in t)o.o(t,r)&&!o.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o.f={},o.e=e=>Promise.all(Object.keys(o.f).reduce((t,r)=>(o.f[r](e,t),t),[])),o.u=e=>"im-openlayers-provider.js",o.miniCssF=e=>{},o.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),e={},t="defra.openLayersProvider:",o.l=(r,n,i,a)=>{if(e[r])e[r].push(n);else{var u,c;if(void 0!==i)for(var f=document.getElementsByTagName("script"),p=0;p<f.length;p++){var s=f[p];if(s.getAttribute("src")==r||s.getAttribute("data-webpack")==t+i){u=s;break}}u||(c=!0,(u=document.createElement("script")).charset="utf-8",o.nc&&u.setAttribute("nonce",o.nc),u.setAttribute("data-webpack",t+i),u.src=r),e[r]=[n];var l=(t,n)=>{u.onerror=u.onload=null,clearTimeout(d);var o=e[r];if(delete e[r],u.parentNode&&u.parentNode.removeChild(u),o&&o.forEach(e=>e(n)),t)return t(n)},d=setTimeout(l.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=l.bind(null,u.onerror),u.onload=l.bind(null,u.onload),c&&document.head.appendChild(u)}},o.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;o.g.importScripts&&(e=o.g.location+"");var t=o.g.document;if(!e&&t&&(t.currentScript&&"SCRIPT"===t.currentScript.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");if(r.length)for(var n=r.length-1;n>-1&&(!e||!/^http(s?):/.test(e));)e=r[n--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),o.p=e})(),(()=>{var e={57:0};o.f.j=(t,r)=>{var n=o.o(e,t)?e[t]:void 0;if(0!==n)if(n)r.push(n[2]);else{var i=new Promise((r,o)=>n=e[t]=[r,o]);r.push(n[2]=i);var a=o.p+o.u(t),u=new Error;o.l(a,r=>{if(o.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var i=r&&("load"===r.type?"missing":r.type),a=r&&r.target&&r.target.src;u.message="Loading chunk "+t+" failed.\n("+i+": "+a+")",u.name="ChunkLoadError",u.type=i,u.request=a,n[1](u)}},"chunk-"+t,t)}};var t=(t,r)=>{var n,i,[a,u,c]=r,f=0;if(a.some(t=>0!==e[t])){for(n in u)o.o(u,n)&&(o.m[n]=u[n]);c&&c(o)}for(t&&t(r);f<a.length;f++)i=a[f],o.o(e,i)&&e[i]&&e[i][0](),e[i]=0},r=this.webpackChunkdefra_DefraMap=this.webpackChunkdefra_DefraMap||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))})();var i={};function a(){var e,t,r="function"==typeof Symbol?Symbol:{},n=r.iterator||"@@iterator",o=r.toStringTag||"@@toStringTag";function i(r,n,o,i){var a=n&&n.prototype instanceof f?n:f,p=Object.create(a.prototype);return u(p,"_invoke",function(r,n,o){var i,a,u,f=0,p=o||[],s=!1,l={p:0,n:0,v:e,a:d,f:d.bind(e,4),d:function(t,r){return i=t,a=0,u=e,l.n=r,c}};function d(r,n){for(a=r,u=n,t=0;!s&&f&&!o&&t<p.length;t++){var o,i=p[t],d=l.p,v=i[2];r>3?(o=v===n)&&(u=i[(a=i[4])?5:(a=3,3)],i[4]=i[5]=e):i[0]<=d&&((o=r<2&&d<i[1])?(a=0,l.v=n,l.n=i[1]):d<v&&(o=r<3||i[0]>n||n>v)&&(i[4]=r,i[5]=n,l.n=v,a=0))}if(o||r>1)return c;throw s=!0,n}return function(o,p,v){if(f>1)throw TypeError("Generator is already running");for(s&&1===p&&d(p,v),a=p,u=v;(t=a<2?e:u)||!s;){i||(a?a<3?(a>1&&(l.n=-1),d(a,u)):l.n=u:l.v=u);try{if(f=2,i){if(a||(o="next"),t=i[o]){if(!(t=t.call(i,u)))throw TypeError("iterator result is not an object");if(!t.done)return t;u=t.value,a<2&&(a=0)}else 1===a&&(t=i.return)&&t.call(i),a<2&&(u=TypeError("The iterator does not provide a '"+o+"' method"),a=1);i=e}else if((t=(s=l.n<0)?u:r.call(n,l))!==c)break}catch(t){i=e,a=1,u=t}finally{f=1}}return{value:t,done:s}}}(r,o,i),!0),p}var c={};function f(){}function p(){}function s(){}t=Object.getPrototypeOf;var l=[][n]?t(t([][n]())):(u(t={},n,function(){return this}),t),d=s.prototype=f.prototype=Object.create(l);function v(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,s):(e.__proto__=s,u(e,o,"GeneratorFunction")),e.prototype=Object.create(d),e}return p.prototype=s,u(d,"constructor",s),u(s,"constructor",p),p.displayName="GeneratorFunction",u(s,o,"GeneratorFunction"),u(d),u(d,o,"Generator"),u(d,n,function(){return this}),u(d,"toString",function(){return"[object Generator]"}),(a=function(){return{w:i,m:v}})()}function u(e,t,r,n){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}u=function(e,t,r,n){function i(t,r){u(e,t,function(e){return this._invoke(t,r,e)})}t?o?o(e,t,{value:r,enumerable:!n,configurable:!n,writable:!n}):e[t]=r:(i("next",0),i("throw",1),i("return",2))},u(e,t,r,n)}function c(e,t,r,n,o,i,a){try{var u=e[i](a),c=u.value}catch(e){return void r(e)}u.done?t(c):Promise.resolve(c).then(n,o)}function f(){var e,t,r=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).zoomAlignment;return{checkDeviceCapabilities:function(){return{isSupported:!!document.createElement("canvas").getContext,error:"Canvas is not supported in this browser"}},load:(e=a().m(function e(){var t,n;return a().w(function(e){for(;;)switch(e.n){case 0:return e.n=1,o.e(142).then(o.bind(o,792));case 1:return t=e.v.default,n={zoomAlignment:r,crs:"EPSG:27700"},e.a(2,{MapProvider:t,mapProviderConfig:n})}},e)}),t=function(){var t=this,r=arguments;return new Promise(function(n,o){var i=e.apply(t,r);function a(e){c(i,n,o,a,u,"next",e)}function u(e){c(i,n,o,a,u,"throw",e)}a(void 0)})},function(){return t.apply(this,arguments)})}}return o.d(i,{default:()=>f}),i.default})());
2
+ !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("defra",[],t):"object"==typeof exports?exports.defra=t():(e.defra=e.defra||{},e.defra.openLayersProvider=t())}(this,()=>(()=>{"use strict";var e,t,r={},n={};function o(e){var t=n[e];if(void 0!==t)return t.exports;var i=n[e]={exports:{}};return r[e](i,i.exports,o),i.exports}o.m=r,o.d=(e,t)=>{for(var r in t)o.o(t,r)&&!o.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o.f={},o.e=e=>Promise.all(Object.keys(o.f).reduce((t,r)=>(o.f[r](e,t),t),[])),o.u=e=>"im-openlayers-provider.js",o.miniCssF=e=>{},o.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),e={},t="defra.openLayersProvider:",o.l=(r,n,i,a)=>{if(e[r])e[r].push(n);else{var u,c;if(void 0!==i)for(var f=document.getElementsByTagName("script"),p=0;p<f.length;p++){var s=f[p];if(s.getAttribute("src")==r||s.getAttribute("data-webpack")==t+i){u=s;break}}u||(c=!0,(u=document.createElement("script")).charset="utf-8",o.nc&&u.setAttribute("nonce",o.nc),u.setAttribute("data-webpack",t+i),u.src=r),e[r]=[n];var l=(t,n)=>{u.onerror=u.onload=null,clearTimeout(d);var o=e[r];if(delete e[r],u.parentNode&&u.parentNode.removeChild(u),o&&o.forEach(e=>e(n)),t)return t(n)},d=setTimeout(l.bind(null,void 0,{type:"timeout",target:u}),12e4);u.onerror=l.bind(null,u.onerror),u.onload=l.bind(null,u.onload),c&&document.head.appendChild(u)}},o.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;o.g.importScripts&&(e=o.g.location+"");var t=o.g.document;if(!e&&t&&(t.currentScript&&"SCRIPT"===t.currentScript.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");if(r.length)for(var n=r.length-1;n>-1&&(!e||!/^http(s?):/.test(e));)e=r[n--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),o.p=e})(),(()=>{var e={57:0};o.f.j=(t,r)=>{var n=o.o(e,t)?e[t]:void 0;if(0!==n)if(n)r.push(n[2]);else{var i=new Promise((r,o)=>n=e[t]=[r,o]);r.push(n[2]=i);var a=o.p+o.u(t),u=new Error;o.l(a,r=>{if(o.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var i=r&&("load"===r.type?"missing":r.type),a=r&&r.target&&r.target.src;u.message="Loading chunk "+t+" failed.\n("+i+": "+a+")",u.name="ChunkLoadError",u.type=i,u.request=a,n[1](u)}},"chunk-"+t,t)}};var t=(t,r)=>{var n,i,[a,u,c]=r,f=0;if(a.some(t=>0!==e[t])){for(n in u)o.o(u,n)&&(o.m[n]=u[n]);c&&c(o)}for(t&&t(r);f<a.length;f++)i=a[f],o.o(e,i)&&e[i]&&e[i][0](),e[i]=0},r=this.webpackChunkdefra_DefraMap=this.webpackChunkdefra_DefraMap||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))})();var i={};function a(){var e,t,r="function"==typeof Symbol?Symbol:{},n=r.iterator||"@@iterator",o=r.toStringTag||"@@toStringTag";function i(r,n,o,i){var a=n&&n.prototype instanceof f?n:f,p=Object.create(a.prototype);return u(p,"_invoke",function(r,n,o){var i,a,u,f=0,p=o||[],s=!1,l={p:0,n:0,v:e,a:d,f:d.bind(e,4),d:function(t,r){return i=t,a=0,u=e,l.n=r,c}};function d(r,n){for(a=r,u=n,t=0;!s&&f&&!o&&t<p.length;t++){var o,i=p[t],d=l.p,v=i[2];r>3?(o=v===n)&&(u=i[(a=i[4])?5:(a=3,3)],i[4]=i[5]=e):i[0]<=d&&((o=r<2&&d<i[1])?(a=0,l.v=n,l.n=i[1]):d<v&&(o=r<3||i[0]>n||n>v)&&(i[4]=r,i[5]=n,l.n=v,a=0))}if(o||r>1)return c;throw s=!0,n}return function(o,p,v){if(f>1)throw TypeError("Generator is already running");for(s&&1===p&&d(p,v),a=p,u=v;(t=a<2?e:u)||!s;){i||(a?a<3?(a>1&&(l.n=-1),d(a,u)):l.n=u:l.v=u);try{if(f=2,i){if(a||(o="next"),t=i[o]){if(!(t=t.call(i,u)))throw TypeError("iterator result is not an object");if(!t.done)return t;u=t.value,a<2&&(a=0)}else 1===a&&(t=i.return)&&t.call(i),a<2&&(u=TypeError("The iterator does not provide a '"+o+"' method"),a=1);i=e}else if((t=(s=l.n<0)?u:r.call(n,l))!==c)break}catch(t){i=e,a=1,u=t}finally{f=1}}return{value:t,done:s}}}(r,o,i),!0),p}var c={};function f(){}function p(){}function s(){}t=Object.getPrototypeOf;var l=[][n]?t(t([][n]())):(u(t={},n,function(){return this}),t),d=s.prototype=f.prototype=Object.create(l);function v(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,s):(e.__proto__=s,u(e,o,"GeneratorFunction")),e.prototype=Object.create(d),e}return p.prototype=s,u(d,"constructor",s),u(s,"constructor",p),p.displayName="GeneratorFunction",u(s,o,"GeneratorFunction"),u(d),u(d,o,"Generator"),u(d,n,function(){return this}),u(d,"toString",function(){return"[object Generator]"}),(a=function(){return{w:i,m:v}})()}function u(e,t,r,n){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}u=function(e,t,r,n){function i(t,r){u(e,t,function(e){return this._invoke(t,r,e)})}t?o?o(e,t,{value:r,enumerable:!n,configurable:!n,writable:!n}):e[t]=r:(i("next",0),i("throw",1),i("return",2))},u(e,t,r,n)}function c(e,t,r,n,o,i,a){try{var u=e[i](a),c=u.value}catch(e){return void r(e)}u.done?t(c):Promise.resolve(c).then(n,o)}function f(){var e,t,r=(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}).zoomAlignment;return{checkDeviceCapabilities:function(){return{isSupported:!!document.createElement("canvas").getContext,error:"Canvas is not supported in this browser"}},load:(e=a().m(function e(){var t,n;return a().w(function(e){for(;;)switch(e.n){case 0:return e.n=1,o.e(142).then(o.bind(o,93));case 1:return t=e.v.default,n={zoomAlignment:r,crs:"EPSG:27700"},e.a(2,{MapProvider:t,mapProviderConfig:n})}},e)}),t=function(){var t=this,r=arguments;return new Promise(function(n,o){var i=e.apply(t,r);function a(e){c(i,n,o,a,u,"next",e)}function u(e){c(i,n,o,a,u,"throw",e)}a(void 0)})},function(){return t.apply(this,arguments)})}}return o.d(i,{default:()=>f}),i.default})());
@@ -1,4 +1,5 @@
1
1
  import XYZ from 'ol/source/XYZ.js'
2
+ import TileWMS from 'ol/source/TileWMS.js'
2
3
  import VectorTileSource from 'ol/source/VectorTile.js'
3
4
  import TileLayer from 'ol/layer/Tile.js'
4
5
  import VectorTileLayer from 'ol/layer/VectorTile.js'
@@ -14,6 +15,7 @@ import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.
14
15
  recordStyleLayer(true)
15
16
 
16
17
  const CRS = 'EPSG:27700'
18
+ const SUPPORTED_MAP_STYLE_TYPES = ['vector', 'raster', 'wms', 'ogc-vt']
17
19
 
18
20
  export function fetchWithTransform (url, resourceType, transformRequest) {
19
21
  const result = transformRequest ? (transformRequest(url, resourceType) || {}) : {}
@@ -30,12 +32,28 @@ const createTileLoadFunction = (transformRequest) => (tile, src) => {
30
32
  .catch(() => tile.setState(TileState.ERROR))
31
33
  }
32
34
 
33
- export function createTileSource (url, transformRequest) {
34
- const tileGrid = new TileGrid({
35
+ function createTileGrid () {
36
+ return new TileGrid({
35
37
  resolutions: TILE_GRID_RESOLUTIONS,
36
38
  origin: TILE_GRID_ORIGIN,
37
39
  tileSize: TILE_SIZE
38
40
  })
41
+ }
42
+
43
+ export function createWMSTileSource (url, params, transformRequest) {
44
+ const tileGrid = createTileGrid()
45
+
46
+ return new TileWMS({
47
+ url,
48
+ params,
49
+ projection: CRS,
50
+ tileGrid,
51
+ tileLoadFunction: transformRequest ? createTileLoadFunction(transformRequest) : undefined
52
+ })
53
+ }
54
+
55
+ export function createTileSource (url, transformRequest) {
56
+ const tileGrid = createTileGrid()
39
57
 
40
58
  const tileUrlFunction = ([z, x, y]) => url
41
59
  .replace('{z}', z)
@@ -51,6 +69,15 @@ export function createTileSource (url, transformRequest) {
51
69
  }
52
70
 
53
71
  export async function createMapStyleLayer (mapStyle, transformRequest) {
72
+ if (mapStyle.type && !SUPPORTED_MAP_STYLE_TYPES.includes(mapStyle.type)) {
73
+ throw new Error(`Unsupported map style type: '${mapStyle.type}'`)
74
+ }
75
+
76
+ if (mapStyle.type === 'wms') {
77
+ const source = createWMSTileSource(mapStyle.url, mapStyle.params || {}, transformRequest)
78
+ return { layer: new TileLayer({ source }), source }
79
+ }
80
+
54
81
  if (mapStyle.type === 'raster') {
55
82
  const source = createTileSource(mapStyle.url, transformRequest)
56
83
  return { layer: new TileLayer({ source }), source }
@@ -1,14 +1,16 @@
1
1
  import XYZ from 'ol/source/XYZ.js'
2
+ import TileWMS from 'ol/source/TileWMS.js'
2
3
  import TileGrid from 'ol/tilegrid/TileGrid.js'
3
4
  import TileLayer from 'ol/layer/Tile.js'
4
5
  import VectorTileSource from 'ol/source/VectorTile.js'
5
6
  import VectorTileLayer from 'ol/layer/VectorTile.js'
6
7
  import { stylefunction } from 'ol-mapbox-style'
7
- import { createTileSource, createMapStyleLayer, createVectorTileLayer } from './tileLayers.js'
8
+ import { createTileSource, createWMSTileSource, createMapStyleLayer, createVectorTileLayer } from './tileLayers.js'
8
9
  import { TILE_GRID_RESOLUTIONS, TILE_GRID_ORIGIN, TILE_SIZE } from '../defaults.js'
9
10
 
10
11
  const mockTileGridInstance = {}
11
12
  const mockSourceInstance = {}
13
+ const mockWMSSourceInstance = {}
12
14
  const mockTileLayerInstance = {}
13
15
  const mockVectorTileSourceInstance = {}
14
16
  const mockOGCVectorTileSourceInstance = { supportedMediaTypes: [] }
@@ -16,6 +18,7 @@ const mockVectorTileLayerInstance = {}
16
18
  const mockMVTInstance = { supportedMediaTypes: [] }
17
19
 
18
20
  jest.mock('ol/source/XYZ.js', () => ({ __esModule: true, default: jest.fn(() => mockSourceInstance) }))
21
+ jest.mock('ol/source/TileWMS.js', () => ({ __esModule: true, default: jest.fn(() => mockWMSSourceInstance) }))
19
22
  jest.mock('ol/layer/Tile.js', () => ({ __esModule: true, default: jest.fn(() => mockTileLayerInstance) }))
20
23
  jest.mock('ol/tilegrid/TileGrid.js', () => ({ __esModule: true, default: jest.fn(() => mockTileGridInstance) }))
21
24
  jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { ERROR: 'error' } }))
@@ -126,6 +129,43 @@ describe('createTileSource', () => {
126
129
  })
127
130
  })
128
131
 
132
+ describe('createWMSTileSource', () => {
133
+ const wmsParams = { LAYERS: 'MyLayer', FORMAT: 'image/jpeg' }
134
+
135
+ beforeEach(() => jest.clearAllMocks())
136
+
137
+ it('creates TileGrid with correct OS tile grid config', () => {
138
+ createWMSTileSource('https://wms.example.com/wms', wmsParams, null)
139
+ expect(TileGrid).toHaveBeenCalledWith({
140
+ resolutions: TILE_GRID_RESOLUTIONS,
141
+ origin: TILE_GRID_ORIGIN,
142
+ tileSize: TILE_SIZE
143
+ })
144
+ })
145
+
146
+ it('creates TileWMS source with EPSG:27700 projection and params', () => {
147
+ createWMSTileSource('https://wms.example.com/wms', wmsParams, null)
148
+ expect(TileWMS).toHaveBeenCalledWith(expect.objectContaining({
149
+ url: 'https://wms.example.com/wms',
150
+ params: wmsParams,
151
+ projection: 'EPSG:27700',
152
+ tileGrid: mockTileGridInstance
153
+ }))
154
+ })
155
+
156
+ it('does not set tileLoadFunction when transformRequest is null', () => {
157
+ createWMSTileSource('https://wms.example.com/wms', wmsParams, null)
158
+ const { tileLoadFunction } = TileWMS.mock.calls[0][0]
159
+ expect(tileLoadFunction).toBeUndefined()
160
+ })
161
+
162
+ it('sets tileLoadFunction when transformRequest is provided', () => {
163
+ createWMSTileSource('https://wms.example.com/wms', wmsParams, jest.fn())
164
+ const { tileLoadFunction } = TileWMS.mock.calls[0][0]
165
+ expect(typeof tileLoadFunction).toBe('function')
166
+ })
167
+ })
168
+
129
169
  describe('tileLoadFunction (via transformRequest)', () => {
130
170
  const url = 'https://tiles.example.com/7/3/5'
131
171
  const mockImg = { src: null }
@@ -191,6 +231,13 @@ describe('createMapStyleLayer', () => {
191
231
  global.fetch = makeVectorFetchMock()
192
232
  })
193
233
 
234
+ it('creates a WMS tile layer and source when mapStyle.type is wms', async () => {
235
+ const params = { LAYERS: 'MyLayer' }
236
+ const result = await createMapStyleLayer({ url: 'https://wms.example.com/wms', type: 'wms', params }, null)
237
+ expect(TileLayer).toHaveBeenCalledWith({ source: mockWMSSourceInstance })
238
+ expect(result).toEqual({ layer: mockTileLayerInstance, source: mockWMSSourceInstance })
239
+ })
240
+
194
241
  it('creates a raster tile layer and source when mapStyle.type is raster', async () => {
195
242
  const result = await createMapStyleLayer({ url: 'https://tiles.example.com/{z}/{x}/{y}', type: 'raster' }, null)
196
243
  expect(TileLayer).toHaveBeenCalledWith({ source: mockSourceInstance })
@@ -210,6 +257,11 @@ describe('createMapStyleLayer', () => {
210
257
  expect(result).toEqual({ layer: mockVectorTileLayerInstance, source: mockVectorTileSourceInstance })
211
258
  })
212
259
 
260
+ it('throws when mapStyle.type is unsupported', async () => {
261
+ await expect(createMapStyleLayer({ url: styleUrl, type: 'unsupported' }, null))
262
+ .rejects.toThrow("Unsupported map style type: 'unsupported'")
263
+ })
264
+
213
265
  it('creates a vector tile layer and source when mapStyle.type is missing', async () => {
214
266
  const result = await createMapStyleLayer({ url: styleUrl }, null)
215
267
  expect(VectorTileLayer).toHaveBeenCalledWith({ source: mockVectorTileSourceInstance, declutter: true })
@@ -1,5 +1,5 @@
1
1
  // components/MapButton.jsx
2
- import React, { useState, useRef } from 'react'
2
+ import React, { useState, useRef, useCallback } from 'react'
3
3
  import { stringToKebab } from '../../../utils/stringToKebab'
4
4
  import { Tooltip } from '../Tooltip/Tooltip'
5
5
  import { Icon } from '../Icon/Icon'
@@ -116,7 +116,7 @@ const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu }) => {
116
116
  * @param {string} options.className - CSS classes for the button
117
117
  * @param {Function} options.onClick - Click event handler
118
118
  * @param {Function} options.onKeyUp - Key up event handler
119
- * @param {Object} options.buttonRefs - React ref object for storing button references
119
+ * @param {Function} options.setButtonRef - Stable ref callback storing the button DOM node in buttonRefs
120
120
  * @param {boolean} options.isDisabled - Whether the button is disabled
121
121
  * @param {boolean} options.isPressed - Whether the button is in pressed state
122
122
  * @param {boolean} options.isExpanded - Whether content controlled by button is expanded
@@ -133,7 +133,7 @@ const buildButtonProps = ({
133
133
  onClick,
134
134
  onKeyUp,
135
135
  onKeyDown,
136
- buttonRefs,
136
+ setButtonRef,
137
137
  isDisabled,
138
138
  isPressed,
139
139
  isExpanded,
@@ -159,16 +159,12 @@ const buildButtonProps = ({
159
159
  onClick,
160
160
  onKeyUp,
161
161
  onKeyDown,
162
- ref: (el) => {
163
- if (buttonRefs.current && buttonId) {
164
- buttonRefs.current[buttonId] = el
165
- }
166
- },
162
+ ref: setButtonRef,
167
163
  'aria-disabled': isDisabled || undefined,
168
164
  'aria-expanded': ariaExpanded,
169
165
  'aria-pressed': typeof isPressed === 'boolean' ? isPressed : undefined,
170
166
  'aria-controls': controlledElement?.id,
171
- 'aria-haspopup': controlledElement?.type === 'popup' || undefined,
167
+ 'aria-haspopup': controlledElement?.type === 'popup' ? 'menu' : undefined,
172
168
  ...(href
173
169
  ? { href, target: '_blank', onKeyUp: handleKeyUp, role: 'button' }
174
170
  : { type: 'button' })
@@ -224,6 +220,14 @@ export const MapButton = ({
224
220
  const [menuRect, setMenuRect] = useState(null)
225
221
  const menuRef = useRef(null)
226
222
 
223
+ // Stable across renders so Preact doesn't detach/reattach it (and transiently
224
+ // null out buttonRefs.current[buttonId]) on every unrelated re-render of this button.
225
+ const setButtonRef = useCallback((el) => {
226
+ if (buttonRefs.current && buttonId) {
227
+ buttonRefs.current[buttonId] = el
228
+ }
229
+ }, [buttonRefs, buttonId])
230
+
227
231
  const Element = href ? 'a' : 'button'
228
232
  const hasMenu = menuItems?.length >= 1
229
233
  const showIcon = iconId || iconSvgContent || hasMenu
@@ -264,7 +268,7 @@ export const MapButton = ({
264
268
  onClick: handleButtonClick,
265
269
  onKeyUp: handleButtonKeyUp,
266
270
  onKeyDown: handleButtonKeyDown,
267
- buttonRefs,
271
+ setButtonRef,
268
272
  isDisabled,
269
273
  isPressed,
270
274
  isExpanded,
@@ -172,7 +172,7 @@
172
172
  }
173
173
 
174
174
  // 4. State styles
175
- .im-c-map-button[aria-haspopup="true"][aria-expanded="true"] svg {
175
+ .im-c-map-button[aria-haspopup="menu"][aria-expanded="true"] svg {
176
176
  transform: rotate(180deg);
177
177
  }
178
178
 
@@ -125,7 +125,7 @@ describe('MapButton', () => {
125
125
  renderButton({ idPrefix: 'prefix', menuItems: [{ label: 'Item' }] })
126
126
  const button = getButton()
127
127
  expect(button).toHaveAttribute('aria-controls', 'prefix-popup-test')
128
- expect(button).toHaveAttribute('aria-haspopup', 'true')
128
+ expect(button).toHaveAttribute('aria-haspopup', 'menu')
129
129
  })
130
130
 
131
131
  it('toggles popup aria-expanded and startPos', () => {
@@ -145,6 +145,75 @@ describe('PopupMenu', () => {
145
145
  expect(mockSetIsOpen).toHaveBeenCalled()
146
146
  })
147
147
 
148
+ describe('Tab key focus handoff', () => {
149
+ let before, instigatorEl, after1, after2
150
+
151
+ beforeEach(() => {
152
+ before = document.createElement('button')
153
+ instigatorEl = document.createElement('button')
154
+ after1 = document.createElement('button')
155
+ after2 = document.createElement('button')
156
+ document.body.append(before, instigatorEl, after1, after2)
157
+ // jsdom never computes layout, so offsetParent is always null — findTabStop
158
+ // treats that as "hidden" and skips it, so it must be stubbed for these to look focusable.
159
+ ;[before, instigatorEl, after1, after2].forEach(el => Object.defineProperty(el, 'offsetParent', { value: true, configurable: true }))
160
+ mockUseApp.buttonRefs.current.instigator = instigatorEl
161
+ })
162
+
163
+ afterEach(() => {
164
+ [before, instigatorEl, after1, after2].forEach(el => el.remove())
165
+ })
166
+
167
+ it('moves focus to the tab stop after the instigator, not the first tab stop on the page', () => {
168
+ renderMenu()
169
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'Tab' })
170
+ expect(document.activeElement).toBe(after1)
171
+ })
172
+
173
+ it('Shift+Tab moves focus to the tab stop before the instigator', () => {
174
+ renderMenu()
175
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'Tab', shiftKey: true })
176
+ expect(document.activeElement).toBe(before)
177
+ })
178
+
179
+ it('falls back to computing the tab stop from the menu itself when the instigator is no longer available', () => {
180
+ mockUseApp.buttonRefs.current.instigator = null
181
+ renderMenu()
182
+ expect(() => fireEvent.keyDown(screen.getByRole('menu'), { key: 'Tab' })).not.toThrow()
183
+ expect(document.activeElement).toBe(before)
184
+ expect(mockUseApp.layoutRefs.viewportRef.current.focus).not.toHaveBeenCalled()
185
+ })
186
+ })
187
+
188
+ describe('Tab key inside a modal dialog', () => {
189
+ let dialogEl, insideBefore, instigatorEl, outsideAfter
190
+
191
+ beforeEach(() => {
192
+ dialogEl = document.createElement('div')
193
+ dialogEl.setAttribute('role', 'dialog')
194
+ dialogEl.setAttribute('aria-modal', 'true')
195
+ insideBefore = document.createElement('button')
196
+ instigatorEl = document.createElement('button')
197
+ dialogEl.append(insideBefore, instigatorEl)
198
+ outsideAfter = document.createElement('button')
199
+ document.body.append(dialogEl, outsideAfter)
200
+ ;[insideBefore, instigatorEl, outsideAfter].forEach(el => Object.defineProperty(el, 'offsetParent', { value: true, configurable: true }))
201
+ mockUseApp.buttonRefs.current.instigator = instigatorEl
202
+ })
203
+
204
+ afterEach(() => {
205
+ dialogEl.remove()
206
+ outsideAfter.remove()
207
+ })
208
+
209
+ it('wraps within the dialog instead of escaping to a tab stop outside it', () => {
210
+ renderMenu()
211
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'Tab' })
212
+ expect(document.activeElement).toBe(insideBefore)
213
+ expect(document.activeElement).not.toBe(outsideAfter)
214
+ })
215
+ })
216
+
148
217
  it('calls item onClick directly if no buttonConfig', () => {
149
218
  renderMenu()
150
219
  fireEvent.click(screen.getByText('Item 1'))
@@ -362,6 +431,16 @@ describe('PopupMenu', () => {
362
431
  expect(focusSpy).toHaveBeenCalled()
363
432
  })
364
433
 
434
+ it('Escape falls back to focusing viewport when instigator is no longer available', () => {
435
+ mockUseApp.buttonRefs.current.instigator = null
436
+ const focusSpy = jest.spyOn(mockUseApp.layoutRefs.viewportRef.current, 'focus')
437
+ renderMenu()
438
+ const ul = screen.getByRole('menu')
439
+ expect(() => fireEvent.keyDown(ul, { key: 'Escape' })).not.toThrow()
440
+ expect(focusSpy).toHaveBeenCalled()
441
+ focusSpy.mockRestore()
442
+ })
443
+
365
444
  it('direct render without startIndex respects startPos="first" (covers initializer and effect)', () => {
366
445
  renderDirect({ startPos: 'first' })
367
446
  expectSelected('Item 1')
@@ -1,5 +1,6 @@
1
1
  import { useState, useMemo, useEffect } from 'react'
2
2
  import { stringToKebab } from '../../../utils/stringToKebab.js'
3
+ import { findTabStop } from '../../../utils/findNextTabStop.js'
3
4
  import { useApp } from '../../store/appContext.js'
4
5
 
5
6
  /**
@@ -86,6 +87,54 @@ const resolveItemFocus = (item, buttonConfig) => ({
86
87
  keepFocus: buttonConfig[item.id]?.keepFocus ?? item.keepFocus
87
88
  })
88
89
 
90
+ /**
91
+ * Focuses the instigator button if it's still in the DOM, otherwise falls back
92
+ * to the map viewport. The instigator can disappear while the menu is open
93
+ * (e.g. app state changes elsewhere remove/disable the triggering button),
94
+ * so callers must not assume it's still focusable.
95
+ *
96
+ * @param {HTMLElement|null} instigator - DOM node of the trigger button, may no longer exist.
97
+ * @param {object} viewportRef - Ref to the map viewport element, used as fallback.
98
+ */
99
+ const focusInstigatorOrViewport = (instigator, viewportRef) => {
100
+ if (instigator) {
101
+ instigator.focus()
102
+ } else {
103
+ viewportRef.current?.focus()
104
+ }
105
+ }
106
+
107
+ // A Panel rendered as a modal dialog (see Panel.jsx's computePanelState/getPanelRole)
108
+ // carries both of these attributes together and traps focus within itself via
109
+ // useModalPanelBehaviour's focusin redirect — tab-stop search must stay inside it.
110
+ const MODAL_DIALOG_SELECTOR = '[role="dialog"][aria-modal="true"]'
111
+
112
+ /**
113
+ * Moves focus to the tab stop immediately after (or before, for Shift+Tab)
114
+ * the instigator button, continuing the page's natural tab sequence rather
115
+ * than returning focus into the popup itself. The popup is rendered via a
116
+ * portal elsewhere in the DOM, so the browser's own Tab default action can't
117
+ * be relied on to continue from the instigator — it must be computed and
118
+ * applied manually. The search is scoped to the nearest modal dialog ancestor
119
+ * (if any): searching the whole document would let it pick a tab stop outside
120
+ * an open modal panel, which the app's own modal focus-trap then immediately
121
+ * yanks focus back from — landing on the trap's container div, which has no
122
+ * visible focus styling, making focus appear to vanish. Falls back to
123
+ * computing the tab stop from the menu element itself if the instigator is
124
+ * gone, so focus still lands on a real neighbouring control rather than
125
+ * jumping to an unrelated part of the app.
126
+ *
127
+ * @param {HTMLElement|null} instigator - DOM node of the trigger button, may no longer exist.
128
+ * @param {boolean} isShiftTab - Whether Shift+Tab (move backwards) was pressed.
129
+ * @param {HTMLElement} fallbackEl - Menu element to anchor from if the instigator is gone.
130
+ */
131
+ const focusAdjacentTabStop = (instigator, isShiftTab, fallbackEl) => {
132
+ const anchor = instigator ?? fallbackEl
133
+ const root = anchor.closest?.(MODAL_DIALOG_SELECTOR) ?? document
134
+ const nextStop = findTabStop({ el: anchor, direction: isShiftTab ? 'prev' : 'next', root })
135
+ nextStop?.focus()
136
+ }
137
+
89
138
  const handleMenuEnter = (event, { items, index, disabledButtons, activateCtx, instigator, dispatch, viewportRef, setIsOpen }) => {
90
139
  event.preventDefault()
91
140
  const item = items[index]
@@ -98,7 +147,7 @@ const handleMenuEnter = (event, { items, index, disabledButtons, activateCtx, in
98
147
  }
99
148
  activateItem(event, item, activateCtx)
100
149
  if (keepFocus) {
101
- instigator.focus()
150
+ focusInstigatorOrViewport(instigator, viewportRef)
102
151
  setIsOpen(false)
103
152
  return
104
153
  }
@@ -122,7 +171,7 @@ const handleMenuSpace = (event, { items, index, disabledButtons, activateCtx, in
122
171
  activateItem(event, item, activateCtx)
123
172
  if (!(item.isPressed !== undefined || item.pressedWhen)) {
124
173
  if (keepFocus) {
125
- instigator.focus()
174
+ focusInstigatorOrViewport(instigator, viewportRef)
126
175
  } else {
127
176
  requestAnimationFrame(() => viewportRef.current?.focus())
128
177
  }
@@ -149,11 +198,9 @@ const handleMenuSpace = (event, { items, index, disabledButtons, activateCtx, in
149
198
  * @returns {Function} onKeyDown handler for the menu element.
150
199
  */
151
200
  const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx, dispatch, viewportRef }) => {
152
- const closeAndFocus = (event, preventDefault = false) => {
153
- if (preventDefault && event?.preventDefault) {
154
- event.preventDefault()
155
- }
156
- instigator.focus()
201
+ const closeAndFocus = (event) => {
202
+ event.preventDefault()
203
+ focusInstigatorOrViewport(instigator, viewportRef)
157
204
  setIsOpen(false)
158
205
  }
159
206
 
@@ -179,11 +226,13 @@ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disa
179
226
 
180
227
  return (event) => {
181
228
  if (['Escape', 'Esc'].includes(event.key)) {
182
- closeAndFocus(event, true)
229
+ closeAndFocus(event)
183
230
  return
184
231
  }
185
232
  if (event.key === 'Tab') {
186
- closeAndFocus(event)
233
+ event.preventDefault()
234
+ focusAdjacentTabStop(instigator, event.shiftKey, event.currentTarget)
235
+ setIsOpen(false)
187
236
  return
188
237
  }
189
238
  if (['ArrowDown', 'ArrowUp'].includes(event.key)) {
@@ -195,7 +244,7 @@ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disa
195
244
  return
196
245
  }
197
246
  if (event.key === 'End' && visibleIndices.length) {
198
- setIndex(visibleIndices[visibleIndices.length - 1])
247
+ setIndex(visibleIndices[visibleIndices.length - 1]) // NOSONAR .length - 1 used instead of .at(-1) for wider browser support
199
248
  return
200
249
  }
201
250
  if (event.key === 'Enter') {
@@ -274,7 +323,7 @@ export const usePopupMenu = ({
274
323
  setIsOpen(false)
275
324
  activateItem(event, item, activateCtx)
276
325
  if (keepFocus) {
277
- instigator.focus()
326
+ focusInstigatorOrViewport(instigator, viewportRef)
278
327
  } else {
279
328
  viewportRef.current?.focus()
280
329
  }
@@ -285,7 +334,7 @@ export const usePopupMenu = ({
285
334
  if (startPos === 'first') {
286
335
  setIndex(visibleIndices[0] ?? -1)
287
336
  } else if (startPos === 'last') {
288
- setIndex(visibleIndices[visibleIndices.length - 1] ?? -1)
337
+ setIndex(visibleIndices[visibleIndices.length - 1] ?? -1) // NOSONAR .length - 1 used instead of .at(-1) for wider browser support
289
338
  } else {
290
339
  // No action
291
340
  }
package/src/types.js CHANGED
@@ -403,18 +403,27 @@
403
403
  * Falls back to `#0b0c0c` (light) or `#ffffff` (dark).
404
404
  * Injected as the `--map-overlay-foreground-color` CSS custom property.
405
405
  *
406
- * @property {'raster' | 'ogc-vt'} [type]
406
+ * @property {'vector' | 'raster' | 'wms' | 'ogc-vt'} [type]
407
407
  * Tile layer type. Controls how the map provider constructs the basemap source. Omit (or leave
408
408
  * undefined) for the default Mapbox GL vector tile path. The OpenLayers provider supports all
409
- * three types, enabling raster, standard vector tile, and OGC vector tile basemaps:
409
+ * four types, enabling raster, WMS, standard vector tile, and OGC vector tile basemaps:
410
410
  * - `'raster'` — XYZ raster tile source; `url` should be a tile URL template.
411
+ * - `'vector'` — Mapbox GL vector tile path; `url` should point to a Mapbox GL style document.
412
+ * - `'wms'` — WMS raster tile source; `url` should be the WMS service endpoint and `params`
413
+ * should provide the WMS request parameters.
414
+ * **Currently only supported by the OpenLayers provider.**
411
415
  * - `'ogc-vt'` — OGC API - Tiles vector tile source; `url` should point to an OGC style endpoint
412
416
  * that returns a Mapbox GL style document. **Currently only supported by the OpenLayers provider.**
413
417
  *
414
418
  * @property {string} url
415
419
  * URL for the style. For the default vector tile path and `'ogc-vt'`, this should be a URL that
416
420
  * returns a Mapbox GL style document (Mapbox Style Specification). For `'raster'`, this should
417
- * be an XYZ tile URL template with `{x}`, `{y}`, `{z}` placeholders.
421
+ * be an XYZ tile URL template with `{x}`, `{y}`, `{z}` placeholders. For `'wms'`, this should
422
+ * be the WMS service endpoint (the GetMap URL).
423
+ *
424
+ * @property {Object} [params]
425
+ * WMS request parameters. Passed directly to the OpenLayers `TileWMS` source when `type` is `'wms'`.
426
+ * Most WMS GetMap requests should include `LAYERS`. Example: `{ LAYERS: 'MyLayer', FORMAT: 'image/jpeg' }`.
418
427
  */
419
428
 
420
429
  /**
@@ -1,7 +1,7 @@
1
- export function findTabStop ({ el, direction }) {
2
- const focusableEls = document.querySelectorAll('input, button, select, textarea, a[href]')
1
+ export function findTabStop ({ el, direction, root = document }) {
2
+ const focusableEls = root.querySelectorAll('input, button, select, textarea, a[href]')
3
3
  const list = Array.prototype.filter.call(focusableEls, item => {
4
- return item.tabIndex >= 0
4
+ return item.tabIndex >= 0 && !!item.offsetParent
5
5
  })
6
6
  const index = list.indexOf(el)
7
7
  return list[direction === 'next' ? index + 1 : index - 1] || list[0]
@@ -7,9 +7,10 @@ import { findTabStop } from './findNextTabStop.js'
7
7
  // --- MOCK SETUP ---
8
8
  // Helper to create mock elements. The default value (tabIndex = 0)
9
9
  // is covered below by creating 'el1' without the second argument.
10
- const createMockElement = (id, tabIndex = 0) => ({
10
+ const createMockElement = (id, tabIndex = 0, offsetParent = {}) => ({
11
11
  id,
12
12
  tabIndex,
13
+ offsetParent,
13
14
  // Required so Jest knows which element is which in the array lookup
14
15
  toString: () => id
15
16
  })
@@ -74,4 +75,27 @@ describe('findTabStop', () => {
74
75
  // Assert: It wraps around to the first element, el1.
75
76
  expect(result).toBe(el1)
76
77
  })
78
+
79
+ // Test 4: Covers: skipping elements with no offsetParent (hidden, e.g. wrong-breakpoint duplicates).
80
+ it('should skip hidden elements (no offsetParent) even when tabIndex is valid', () => {
81
+ const el1 = createMockElement('el1', 0)
82
+ const hiddenEl = createMockElement('hidden', 0, null)
83
+ const el3 = createMockElement('el3', 0)
84
+
85
+ mockFocusableElements([el1, hiddenEl, el3])
86
+
87
+ const result = findTabStop({ el: el1, direction: 'next' })
88
+
89
+ expect(result).toBe(el3)
90
+ })
91
+
92
+ it('accepts a custom root to scope the search', () => {
93
+ const el1 = createMockElement('el1')
94
+ const scopedRoot = { querySelectorAll: jest.fn(() => [el1]) }
95
+
96
+ findTabStop({ el: el1, direction: 'next', root: scopedRoot })
97
+
98
+ expect(scopedRoot.querySelectorAll).toHaveBeenCalledTimes(1)
99
+ expect(document.querySelectorAll).not.toHaveBeenCalled()
100
+ })
77
101
  })
package/webpack.dev.mjs CHANGED
@@ -87,6 +87,7 @@ export default {
87
87
  NGD_BLACK_AND_WHITE_URL_27700: JSON.stringify(process.env.NGD_BLACK_AND_WHITE_URL_27700),
88
88
  // Aerial photography
89
89
  AERIAL_URL: JSON.stringify(process.env.AERIAL_URL),
90
+ APGB_WMS_URL: JSON.stringify(process.env.APGB_WMS_URL),
90
91
  // OS Maps API (27700 raster)
91
92
  MAPS_OUTDOOR_URL: JSON.stringify(process.env.MAPS_OUTDOOR_URL),
92
93
  MAPS_ROAD_URL: JSON.stringify(process.env.MAPS_ROAD_URL),