@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/map-style-config.md +27 -3
- package/docs/assets/images/hero.png +0 -0
- package/package.json +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/src/DrawInit.jsx +11 -0
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +31 -25
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +1 -1
- package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +5 -4
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +49 -21
- package/plugins/beta/draw-ol/dist/esm/DrawMode.js +1 -1
- package/plugins/beta/draw-ol/dist/esm/EditMode.js +1 -1
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
- package/plugins/beta/draw-ol/src/core/OLDrawManager.js +3 -0
- package/plugins/beta/draw-ol/src/draw/DrawMode.js +57 -49
- package/plugins/beta/draw-ol/src/draw/drawInput.js +76 -32
- package/plugins/beta/draw-ol/src/edit/EditMode.js +4 -4
- package/plugins/beta/draw-ol/src/manifest.js +5 -1
- package/plugins/beta/draw-ol/src/reducer.js +1 -1
- package/plugins/beta/draw-ol/src/snap/snapEngine.js +75 -31
- package/plugins/beta/draw-ol/src/snap/snapGeometry.js +23 -46
- package/plugins/beta/draw-ol/src/snap/snapInteraction.js +30 -29
- package/plugins/beta/draw-ol/src/snap/snapManager.js +10 -1
- package/plugins/beta/draw-ol/src/utils/geometryHelpers.js +9 -3
- package/plugins/beta/draw-ol/src/utils/olCoords.js +2 -2
- package/plugins/beta/draw-ol/src/utils/spatial.js +4 -8
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/index.js +1 -1
- package/providers/beta/openlayers/src/utils/tileLayers.js +29 -2
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +53 -1
- package/src/App/components/MapButton/MapButton.jsx +14 -10
- package/src/App/components/MapButton/MapButton.module.scss +1 -1
- package/src/App/components/MapButton/MapButton.test.jsx +1 -1
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +79 -0
- package/src/App/components/PopupMenu/usePopupMenu.js +61 -12
- package/src/types.js +12 -3
- package/src/utils/findNextTabStop.js +3 -3
- package/src/utils/findNextTabStop.test.js +25 -1
- 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,
|
|
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
|
-
|
|
34
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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'
|
|
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
|
-
|
|
271
|
+
setButtonRef,
|
|
268
272
|
isDisabled,
|
|
269
273
|
isPressed,
|
|
270
274
|
isExpanded,
|
|
@@ -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', '
|
|
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
|
|
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
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
229
|
+
closeAndFocus(event)
|
|
183
230
|
return
|
|
184
231
|
}
|
|
185
232
|
if (event.key === 'Tab') {
|
|
186
|
-
|
|
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
|
|
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
|
-
*
|
|
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 =
|
|
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),
|