diff --git a/demo/js/index.js b/demo/js/index.js
index 7cf280c3..a09b73db 100755
--- a/demo/js/index.js
+++ b/demo/js/index.js
@@ -22,8 +22,10 @@ var interactPlugin = createInteractPlugin({
},{
layerId: 'linked-parcels',
// idProperty: 'id'
+ },{
+ layerId: 'OS/TopographicArea_1/Agricultural Land'
}],
- interactionMode: 'auto', // 'auto', 'select', 'marker' // defaults to 'marker'
+ interactionMode: 'select', // 'auto', 'select', 'marker' // defaults to 'marker'
multiSelect: true,
contiguous: true,
// excludeModes: ['draw']
@@ -106,6 +108,7 @@ var interactiveMap = new InteractiveMap('map', {
containerHeight: '650px',
transformRequest: transformTileRequest,
enableZoomControls: true,
+ readMapText: true,
// enableFullscreen: true,
// hasExitButton: true,
// markers: [{
@@ -149,7 +152,6 @@ interactiveMap.on('app:ready', function (e) {
})
interactiveMap.on('map:ready', function (e) {
- // console.log('map:ready')
// framePlugin.addFrame('test', {
// aspectRatio: 1
// })
@@ -178,7 +180,7 @@ interactiveMap.on('draw:ready', function () {
// drawPlugin.split('test1234', {
// snapLayers: ['OS/TopographicArea_1/Agricultural Land']
// })
- // drawPlugin.newLine('test', {
+ // drawPlugin.newPolygon('test', {
// snapLayers: ['OS/TopographicArea_1/Agricultural Land']
// })
// drawPlugin.editFeature('test1234')
diff --git a/demo/js/planning.js b/demo/js/planning.js
index 75db6fe6..2fdb62e1 100755
--- a/demo/js/planning.js
+++ b/demo/js/planning.js
@@ -131,7 +131,7 @@ const interactiveMap = new InteractiveMap('map', {
// search
})
-interactiveMap.on('map:ready', function (e) {
+interactiveMap.on('app:ready', function (e) {
interactiveMap.addButton('menu', {
label: 'Menu',
panelId: 'menu',
diff --git a/docs/api.md b/docs/api.md
index 104a7ea4..389dc0e2 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -388,6 +388,14 @@ See [PluginDescriptor](./plugins/plugin-descriptor.md) for full details.
---
+### `preserveStateOnClose`
+**Type:** `boolean`
+**Default:** `false`
+
+Controls whether closing the map (via the browser back button or the exit map button when `hasExitButton` is `true` and the map is fullscreen) destroys the map instance or hides it while preserving its current state. Set to `true` to keep the map state intact, which is useful for implementations like a toggle map view list view pattern.
+
+---
+
### `readMapText`
**Type:** `boolean`
**Default:** `false`
diff --git a/plugins/beta/draw-ml/src/mapboxSnap.js b/plugins/beta/draw-ml/src/mapboxSnap.js
index ddbd98a7..d5ba14b9 100644
--- a/plugins/beta/draw-ml/src/mapboxSnap.js
+++ b/plugins/beta/draw-ml/src/mapboxSnap.js
@@ -140,6 +140,8 @@ function applyMapboxSnapPatches(colors) {
function pollUntil(checkFn, onSuccess) {
(function poll() {
const result = checkFn()
+ // null signals to stop polling, falsy continues polling
+ if (result === null) return
result ? onSuccess(result) : requestAnimationFrame(poll)
})()
}
@@ -267,7 +269,7 @@ export function initMapLibreSnap(map, draw, snapOptions = {}) {
// Handle style changes - re-patch source and ensure snap layer exists
map.on('style.load', () => {
pollUntil(
- () => map.getSource('mapbox-gl-draw-hot'),
+ () => map._removed ? null : map.getSource('mapbox-gl-draw-hot'),
(source) => {
patchSourceData(source)
@@ -312,7 +314,7 @@ export function initMapLibreSnap(map, draw, snapOptions = {}) {
// Initial setup - poll until draw source exists
pollUntil(
- () => map.getSource('mapbox-gl-draw-hot'),
+ () => map._removed ? null : map.getSource('mapbox-gl-draw-hot'),
createSnap
)
}
diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js
index 855d32a5..af216b21 100755
--- a/plugins/interact/src/hooks/useInteractionHandlers.js
+++ b/plugins/interact/src/hooks/useInteractionHandlers.js
@@ -62,7 +62,14 @@ export const useInteractionHandlers = ({
useEffect(() => {
// Skip if features exist but bounds not yet calculated
const awaitingBounds = selectedFeatures.length > 0 && !selectionBounds
- if (awaitingBounds || selectedFeatures === lastEmittedSelectionChange.current) {
+ if (awaitingBounds) {
+ return
+ }
+
+ // Skip if selection was already empty and remains empty
+ const prev = lastEmittedSelectionChange.current
+ const wasEmpty = prev === null || prev.length === 0
+ if (wasEmpty && selectedFeatures.length === 0) {
return
}
diff --git a/providers/maplibre/src/utils/labels.js b/providers/maplibre/src/utils/labels.js
index 001cdba7..5d1e1442 100755
--- a/providers/maplibre/src/utils/labels.js
+++ b/providers/maplibre/src/utils/labels.js
@@ -2,8 +2,9 @@ import { spatialNavigate } from './spatial.js'
import { calculateLinearTextSize } from './calculateLinearTextSize.js'
const HIGHLIGHT_SCALE_FACTOR = 1.5
+const HIGHLIGHT_LABEL_SOURCE = 'highlighted-label'
-function getGeometryCenter(geometry) {
+export function getGeometryCenter(geometry) {
const { type, coordinates } = geometry
if (type === 'Point') {
return coordinates
@@ -23,13 +24,12 @@ function getGeometryCenter(geometry) {
return null
}
-function evalInterpolate(expr, zoom) {
+export function evalInterpolate(expr, zoom) {
if (typeof expr === 'number') {
return expr
}
if (!Array.isArray(expr) || expr[0] !== 'interpolate') {
return calculateLinearTextSize(expr, zoom)
- // throw new Error('Only interpolate expressions supported')
}
const [, , input, ...stops] = expr
if (input[0] !== 'zoom') {
@@ -39,7 +39,7 @@ function evalInterpolate(expr, zoom) {
const z0 = stops[i]
const v0 = stops[i + 1]
const z1 = stops[i + 2]
- const v1 = stops[i + 3]
+ const v1 = stops[i + 3] // NOSONAR: array index offset for interpolation pairs
if (zoom <= z0) {
return v0
}
@@ -50,166 +50,220 @@ function evalInterpolate(expr, zoom) {
return stops[stops.length - 1]
}
-export function createMapLabelNavigator(map, mapColorScheme, events, eventBus) {
- let isDarkStyle = mapColorScheme === 'dark'
- let labels = []
- let currentPixel = null
- let highlightLayerId = null
- let highlightedExpr = null
- let highlightedFeature = null
-
- const colors = {
- get current() {
- if (isDarkStyle) {
- return { text: '#ffffff', halo: '#000000' }
- }
- return { text: '#000000', halo: '#ffffff' }
- }
+export function getHighlightColors(isDarkStyle) {
+ if (isDarkStyle) {
+ return { text: '#ffffff', halo: '#000000' }
}
+ return { text: '#000000', halo: '#ffffff' }
+}
- const initLabelSource = () => {
- if (!map.getSource('highlighted-label')) {
- map.addSource('highlighted-label', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
- }
+export function extractTextPropertyName(textField) {
+ if (typeof textField === 'string') {
+ return /^{(.+)}$/.exec(textField)?.[1]
}
+ if (Array.isArray(textField)) {
+ return textField.find(e => Array.isArray(e) && e[0] === 'get')?.[1]
+ }
+ return null
+}
- map.getStyle().layers.filter(l => l.layout?.['symbol-placement'] === 'line').forEach(l => {
- map.setLayoutProperty(l.id, 'symbol-placement', 'line-center')
- })
- initLabelSource()
+export function buildLabelFromFeature(feature, layer, propName, map) {
+ const center = getGeometryCenter(feature.geometry)
+ if (!center) {
+ return null
+ }
+ const projected = map.project({ lng: center[0], lat: center[1] })
+ return { text: feature.properties[propName], x: projected.x, y: projected.y, feature, layer }
+}
- eventBus?.on(events.MAP_SET_STYLE, style => {
- map.once('styledata', () => map.once('idle', () => {
- map.getStyle().layers.filter(l => l.layout?.['symbol-placement'] === 'line').forEach(l => {
- map.setLayoutProperty(l.id, 'symbol-placement', 'line-center')
- })
- initLabelSource()
- isDarkStyle = style?.mapColorScheme === 'dark'
- }))
+export function buildLabelsFromLayers(map, symbolLayers, features) {
+ return symbolLayers.flatMap(layer => {
+ const textField = layer.layout?.['text-field']
+ const propName = extractTextPropertyName(textField)
+ if (!propName) {
+ return []
+ }
+ return features
+ .filter(f => f.layer.id === layer.id && f.properties?.[propName])
+ .map(f => buildLabelFromFeature(f, layer, propName, map))
+ .filter(Boolean)
})
+}
- function refreshLabels() {
- const symbolLayers = map.getStyle().layers.filter(l => l.type === 'symbol')
- const features = map.queryRenderedFeatures({ layers: symbolLayers.map(l => l.id) })
- labels = symbolLayers.flatMap(layer => {
- const textField = layer.layout?.['text-field']
- const propName = typeof textField === 'string'
- ? textField.match(/^{(.+)}$/)?.[1]
- : Array.isArray(textField)
- ? textField.find(e => Array.isArray(e) && e[0] === 'get')?.[1]
- : null
- if (!propName) {
- return []
- }
- return features.filter(f => f.layer.id === layer.id && f.properties?.[propName]).map(f => {
- const center = getGeometryCenter(f.geometry)
- if (!center) {
- return null
- }
- const projected = map.project({ lng: center[0], lat: center[1] })
- return { text: f.properties[propName], x: projected.x, y: projected.y, feature: f, layer }
- }).filter(Boolean)
- })
- }
+export function findClosestLabel(labels, centerPoint) {
+ return labels.reduce((best, label) => {
+ const dist = (label.x - centerPoint.x) ** 2 + (label.y - centerPoint.y) ** 2
+ if (!best || dist < best.dist) {
+ return { label, dist }
+ }
+ return best
+ }, null)?.label
+}
- function removeHighlight() {
- if (highlightLayerId && map.getLayer(highlightLayerId)) {
- try {
- map.removeLayer(highlightLayerId)
- }
- catch {}
- highlightLayerId = null
- highlightedExpr = null
- highlightedFeature = null
+export function createHighlightLayerConfig(sourceLayer, highlightSize, colors) {
+ return {
+ id: `highlight-${sourceLayer.id}`,
+ type: sourceLayer.type,
+ source: HIGHLIGHT_LABEL_SOURCE,
+ layout: {
+ ...sourceLayer.layout,
+ 'text-size': highlightSize,
+ 'text-allow-overlap': true,
+ 'text-ignore-placement': true,
+ 'text-max-angle': 90
+ },
+ paint: {
+ ...sourceLayer.paint,
+ 'text-color': colors.text,
+ 'text-halo-color': colors.halo,
+ 'text-halo-width': 3,
+ 'text-halo-blur': 1,
+ 'text-opacity': 1
}
}
+}
- function highlight(labelData) {
- if (!labelData?.feature?.layer) {
- return
+export function removeHighlightLayer(map, state) {
+ if (state.highlightLayerId && map.getLayer(state.highlightLayerId)) {
+ try {
+ map.removeLayer(state.highlightLayerId)
}
- removeHighlight()
- const { feature, layer } = labelData
- highlightLayerId = `highlight-${layer.id}`
-
- const { id, type, properties, geometry } = feature
- highlightedFeature = { id, type, properties, geometry }
- map.getSource('highlighted-label').setData(highlightedFeature)
- highlightedExpr = layer.layout['text-size']
- const zoom = map.getZoom()
- const baseSize = evalInterpolate(highlightedExpr, zoom)
- const highlightSize = baseSize * HIGHLIGHT_SCALE_FACTOR
- map.addLayer({
- id: highlightLayerId,
- type: layer.type,
- source: 'highlighted-label',
- layout: { ...layer.layout, 'text-size': highlightSize, 'text-allow-overlap': true, 'text-ignore-placement': true, 'text-max-angle': 90 },
- paint: { ...layer.paint, 'text-color': colors.current.text, 'text-halo-color': colors.current.halo, 'text-halo-width': 3, 'text-halo-blur': 1, 'text-opacity': 1 }
+ catch {}
+ state.highlightLayerId = null
+ state.highlightedExpr = null
+ }
+}
+
+export function applyHighlight(map, labelData, state) {
+ if (!labelData?.feature?.layer) {
+ return
+ }
+ removeHighlightLayer(map, state)
+ const { feature, layer } = labelData
+ state.highlightLayerId = `highlight-${layer.id}`
+
+ const { id, type, properties, geometry } = feature
+ map.getSource(HIGHLIGHT_LABEL_SOURCE).setData({ id, type, properties, geometry })
+ state.highlightedExpr = layer.layout['text-size']
+
+ const zoom = map.getZoom()
+ const baseSize = evalInterpolate(state.highlightedExpr, zoom)
+ const highlightSize = baseSize * HIGHLIGHT_SCALE_FACTOR
+ const colors = getHighlightColors(state.isDarkStyle)
+ const layerConfig = createHighlightLayerConfig(layer, highlightSize, colors)
+
+ map.addLayer(layerConfig)
+ map.moveLayer(state.highlightLayerId)
+}
+
+export function navigateToNextLabel(direction, state) {
+ if (!state.currentPixel) {
+ return null
+ }
+ const filtered = state.labels
+ .map((l, i) => ({ pixel: [l.x, l.y], index: i }))
+ .filter(l => l.pixel[0] !== state.currentPixel.x || l.pixel[1] !== state.currentPixel.y)
+ if (!filtered.length) {
+ return null
+ }
+ const pixelArray = filtered.map(l => l.pixel)
+ let nextFilteredIndex = spatialNavigate(direction, [state.currentPixel.x, state.currentPixel.y], pixelArray)
+ if (nextFilteredIndex == null || nextFilteredIndex < 0 || nextFilteredIndex >= filtered.length) {
+ nextFilteredIndex = 0
+ }
+ return state.labels[filtered[nextFilteredIndex].index]
+}
+
+function initLabelSource(map) {
+ if (!map.getSource(HIGHLIGHT_LABEL_SOURCE)) {
+ map.addSource(HIGHLIGHT_LABEL_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
+ }
+}
+
+function setLineCenterPlacement(map) {
+ map.getStyle().layers
+ .filter(l => l.layout?.['symbol-placement'] === 'line')
+ .forEach(l => map.setLayoutProperty(l.id, 'symbol-placement', 'line-center'))
+}
+
+function setSymbolTextOpacity(map) {
+ map.getStyle().layers
+ .filter(l => l.type === 'symbol')
+ .forEach(layer => {
+ map.setPaintProperty(layer.id, 'text-opacity', ['case', ['boolean', ['feature-state', 'highlighted'], false], 0, 1])
})
- map.moveLayer(highlightLayerId)
+}
+
+export function createMapLabelNavigator(map, mapColorScheme, events, eventBus) {
+ const state = {
+ isDarkStyle: mapColorScheme === 'dark',
+ labels: [],
+ currentPixel: null,
+ highlightLayerId: null,
+ highlightedExpr: null
}
+ setLineCenterPlacement(map)
+ initLabelSource(map)
+
+ eventBus?.on(events.MAP_SET_STYLE, style => {
+ map.once('styledata', () => map.once('idle', () => {
+ setLineCenterPlacement(map)
+ initLabelSource(map)
+ state.isDarkStyle = style?.mapColorScheme === 'dark'
+ }))
+ })
+
map.on('zoom', () => {
- if (highlightLayerId && highlightedExpr) {
- const zoom = map.getZoom()
- const baseSize = evalInterpolate(highlightedExpr, zoom)
- map.setLayoutProperty(highlightLayerId, 'text-size', baseSize * HIGHLIGHT_SCALE_FACTOR)
+ if (state.highlightLayerId && state.highlightedExpr) {
+ const baseSize = evalInterpolate(state.highlightedExpr, map.getZoom())
+ map.setLayoutProperty(state.highlightLayerId, 'text-size', baseSize * HIGHLIGHT_SCALE_FACTOR)
}
})
+ function refreshLabels() {
+ const symbolLayers = map.getStyle().layers.filter(l => l.type === 'symbol')
+ const features = map.queryRenderedFeatures({ layers: symbolLayers.map(l => l.id) })
+ state.labels = buildLabelsFromLayers(map, symbolLayers, features)
+ }
+
function highlightCenter() {
refreshLabels()
- if (!labels.length) {
+ if (!state.labels.length) {
return null
}
const centerPoint = map.project(map.getCenter())
- const closest = labels.reduce((best, label) => {
- const dist = (label.x - centerPoint.x) ** 2 + (label.y - centerPoint.y) ** 2
- if (!best || dist < best.dist) {
- return { label, dist }
- }
- return best
- }, null)?.label
+ const closest = findClosestLabel(state.labels, centerPoint)
if (closest) {
- currentPixel = { x: closest.x, y: closest.y }
+ state.currentPixel = { x: closest.x, y: closest.y }
}
- highlight(closest)
+ applyHighlight(map, closest, state)
return `${closest.text} (${closest.layer.id})`
}
function highlightNext(direction) {
refreshLabels()
- if (!labels.length) {
+ if (!state.labels.length) {
return null
}
- if (!currentPixel) {
+ if (!state.currentPixel) {
return highlightCenter()
}
- const filtered = labels
- .map((l, i) => ({ pixel: [l.x, l.y], index: i }))
- .filter(l => l.pixel[0] !== currentPixel.x || l.pixel[1] !== currentPixel.y)
- if (!filtered.length) {
+ const labelData = navigateToNextLabel(direction, state)
+ if (!labelData) {
return null
}
- const pixelArray = filtered.map(l => l.pixel)
- let nextFilteredIndex = spatialNavigate(direction, [currentPixel.x, currentPixel.y], pixelArray)
- if (nextFilteredIndex == null || nextFilteredIndex < 0 || nextFilteredIndex >= filtered.length) {
- nextFilteredIndex = 0
- }
- const labelData = labels[filtered[nextFilteredIndex].index]
- currentPixel = { x: labelData.x, y: labelData.y }
- highlight(labelData)
+ state.currentPixel = { x: labelData.x, y: labelData.y }
+ applyHighlight(map, labelData, state)
return `${labelData.text} (${labelData.layer.id})`
}
- map.getStyle().layers.filter(l => l.type === 'symbol').forEach(layer => {
- map.setPaintProperty(layer.id, 'text-opacity', ['case', ['boolean', ['feature-state', 'highlighted'], false], 0, 1])
- })
+ setSymbolTextOpacity(map)
return {
refreshLabels,
highlightNextLabel: highlightNext,
highlightLabelAtCenter: highlightCenter,
- clearHighlightedLabel: removeHighlight
+ clearHighlightedLabel: () => removeHighlightLayer(map, state)
}
}
diff --git a/sonar-project.properties b/sonar-project.properties
index c6e42061..f250107f 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -16,8 +16,23 @@ sonar.tests=src,plugins,providers
sonar.test.inclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**
sonar.cpd.exclusions=**/*.test.*,**/__mocks__/**,**/__stubs__/**
-sonar.issue.ignore.multicriteria=reactPropsJs,reactPropsJsx
+# Ignored rules
+
+# S6774: React props validation - using TypeScript/JSDoc for prop types instead
+sonar.issue.ignore.multicriteria=reactPropsJs,reactPropsJsx,preferGlobalThisJs,preferGlobalThisJsx,preferAtJs,preferAtJsx
sonar.issue.ignore.multicriteria.reactPropsJs.ruleKey=javascript:S6774
sonar.issue.ignore.multicriteria.reactPropsJs.resourceKey=**/*.js
sonar.issue.ignore.multicriteria.reactPropsJsx.ruleKey=javascript:S6774
-sonar.issue.ignore.multicriteria.reactPropsJsx.resourceKey=**/*.jsx
\ No newline at end of file
+sonar.issue.ignore.multicriteria.reactPropsJsx.resourceKey=**/*.jsx
+
+# S7764: Prefer globalThis over window - globalThis cannot be polyfilled for older browsers
+sonar.issue.ignore.multicriteria.preferGlobalThisJs.ruleKey=javascript:S7764
+sonar.issue.ignore.multicriteria.preferGlobalThisJs.resourceKey=**/*.js
+sonar.issue.ignore.multicriteria.preferGlobalThisJsx.ruleKey=javascript:S7764
+sonar.issue.ignore.multicriteria.preferGlobalThisJsx.resourceKey=**/*.jsx
+
+# S7755: Prefer .at() over [array.length - index] - .at() is ES2022 and requires polyfilling
+sonar.issue.ignore.multicriteria.preferAtJs.ruleKey=javascript:S7755
+sonar.issue.ignore.multicriteria.preferAtJs.resourceKey=**/*.js
+sonar.issue.ignore.multicriteria.preferAtJsx.ruleKey=javascript:S7755
+sonar.issue.ignore.multicriteria.preferAtJsx.resourceKey=**/*.jsx
\ No newline at end of file
diff --git a/src/App/components/KeyboardHelp/KeyboardHelp.jsx b/src/App/components/KeyboardHelp/KeyboardHelp.jsx
index bb5f7ddf..7e159995 100755
--- a/src/App/components/KeyboardHelp/KeyboardHelp.jsx
+++ b/src/App/components/KeyboardHelp/KeyboardHelp.jsx
@@ -1,11 +1,13 @@
// src/components/KeyboardHelp.jsx
import React from 'react'
+import { useConfig } from '../../store/configContext'
import { getKeyboardShortcuts } from '../../registry/keyboardShortcutRegistry.js'
// eslint-disable-next-line camelcase, react/jsx-pascal-case
// sonarjs/disable-next-line function-name
export const KeyboardHelp = () => {
- const groups = getKeyboardShortcuts().reduce((acc, shortcut) => {
+ const appConfig = useConfig()
+ const groups = getKeyboardShortcuts(appConfig).reduce((acc, shortcut) => {
acc[shortcut.group] = acc[shortcut.group] || []
acc[shortcut.group].push(shortcut)
return acc
diff --git a/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx b/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx
index 1e74e8a8..53f808fe 100755
--- a/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx
+++ b/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx
@@ -3,12 +3,21 @@ import React from 'react'
import { render, screen, within } from '@testing-library/react'
import { KeyboardHelp } from './KeyboardHelp'
import { getKeyboardShortcuts } from '../../registry/keyboardShortcutRegistry.js'
+import { useConfig } from '../../store/configContext'
jest.mock('../../registry/keyboardShortcutRegistry.js', () => ({
getKeyboardShortcuts: jest.fn()
}))
+jest.mock('../../store/configContext', () => ({
+ useConfig: jest.fn()
+}))
+
describe('KeyboardHelp', () => {
+ beforeEach(() => {
+ useConfig.mockReturnValue({})
+ })
+
afterEach(() => {
jest.clearAllMocks()
})
diff --git a/src/App/controls/keyboardShortcuts.js b/src/App/controls/keyboardShortcuts.js
index 71f602a3..c9f52018 100755
--- a/src/App/controls/keyboardShortcuts.js
+++ b/src/App/controls/keyboardShortcuts.js
@@ -48,13 +48,15 @@ export const coreShortcuts = [
group: 'Labels',
title: 'Highlight label at centre',
command: 'Alt + Enter',
- enabled: false
+ enabled: false,
+ requiredConfig: ['readMapText']
},
{
id: 'highlightNextLabel',
group: 'Labels',
title: 'Highlight nearby label',
command: 'Alt + →, ←, ↑ or ↓',
- enabled: false
+ enabled: false,
+ requiredConfig: ['readMapText']
}
]
diff --git a/src/App/initialiseApp.js b/src/App/initialiseApp.js
index 657daef1..a905b63e 100755
--- a/src/App/initialiseApp.js
+++ b/src/App/initialiseApp.js
@@ -1,6 +1,6 @@
import { createRoot } from 'react-dom/client'
import { EVENTS as events } from '../config/events.js'
-import { appConfig } from '../config/appConfig.js'
+import { defaultAppConfig } from '../config/appConfig.js'
import { createButtonRegistry } from './registry/buttonRegistry.js'
import { createPanelRegistry } from './registry/panelRegistry.js'
import { createControlRegistry } from './registry/controlRegistry.js'
@@ -59,7 +59,7 @@ export async function initialiseApp (rootElement, {
// Register default appConfig as a plugin
registerPlugin({
id: 'appConfig',
- manifest: appConfig
+ manifest: defaultAppConfig
})
// Create root if not already present
diff --git a/src/App/registry/keyboardShortcutRegistry.js b/src/App/registry/keyboardShortcutRegistry.js
index c553eef7..b00c5ec3 100755
--- a/src/App/registry/keyboardShortcutRegistry.js
+++ b/src/App/registry/keyboardShortcutRegistry.js
@@ -21,8 +21,18 @@ export const setProviderSupportedShortcuts = (ids = []) => {
providerSupportedIds = new Set(ids)
}
-export const getKeyboardShortcuts = () => {
- const filteredCore = coreShortcuts.filter(s => providerSupportedIds.has(s.id))
+export const getKeyboardShortcuts = (appConfig = {}) => {
+ const filteredCore = coreShortcuts.filter(s => {
+ // Must be supported by provider
+ if (!providerSupportedIds.has(s.id)) {
+ return false
+ }
+ // Check requiredConfig - all specified config values must be truthy
+ if (s.requiredConfig) {
+ return s.requiredConfig.every(key => appConfig[key])
+ }
+ return true
+ })
return [
...filteredCore, // supported core shortcuts
diff --git a/src/App/registry/keyboardShortcutRegistry.test.js b/src/App/registry/keyboardShortcutRegistry.test.js
index 6f593f5e..0aea5531 100755
--- a/src/App/registry/keyboardShortcutRegistry.test.js
+++ b/src/App/registry/keyboardShortcutRegistry.test.js
@@ -70,4 +70,32 @@ describe('keyboardShortcutRegistry', () => {
const shortcuts = getKeyboardShortcuts()
expect(shortcuts).toEqual([])
})
+
+ test('getKeyboardShortcuts filters by requiredConfig when appConfig provided', () => {
+ jest.resetModules()
+ jest.doMock('../controls/keyboardShortcuts.js', () => ({
+ coreShortcuts: [
+ { id: 'always', description: 'Always shown' },
+ { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] }
+ ]
+ }))
+ const module = require('./keyboardShortcutRegistry.js')
+ module.setProviderSupportedShortcuts(['always', 'conditional'])
+
+ // Without config, conditional shortcut is excluded
+ expect(module.getKeyboardShortcuts()).toEqual([
+ { id: 'always', description: 'Always shown' }
+ ])
+
+ // With config false, conditional shortcut is excluded
+ expect(module.getKeyboardShortcuts({ featureEnabled: false })).toEqual([
+ { id: 'always', description: 'Always shown' }
+ ])
+
+ // With config true, conditional shortcut is included
+ expect(module.getKeyboardShortcuts({ featureEnabled: true })).toEqual([
+ { id: 'always', description: 'Always shown' },
+ { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] }
+ ])
+ })
})
diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js
index 93119a80..e9ae284a 100755
--- a/src/InteractiveMap/InteractiveMap.js
+++ b/src/InteractiveMap/InteractiveMap.js
@@ -22,6 +22,7 @@ import { createInterfaceDetector, getInterfaceType } from '../utils/detectInterf
import { createReverseGeocode } from '../services/reverseGeocode.js'
import { EVENTS as events } from '../config/events.js'
import { createEventBus } from '../services/eventBus.js'
+import { toggleInertElements } from '../utils/toggleInertElements.js'
/**
* Main entry point for the Interactive Map component.
@@ -33,6 +34,7 @@ export default class InteractiveMap {
_breakpointDetector = null
_interfaceDetectorCleanup = null
_hybridBehaviourCleanup = null
+ _isHidden = false // tracks if map is hidden but preserved (hybrid mode)
/**
* Create a new InteractiveMap instance.
@@ -98,12 +100,20 @@ export default class InteractiveMap {
}
_handleButtonClick (e) {
- this.loadApp()
history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
+ if (this._isHidden) {
+ this.showApp()
+ } else {
+ this.loadApp()
+ }
}
_handleExitClick () {
- this.removeApp()
+ if (this.config.preserveStateOnClose) {
+ this.hideApp()
+ } else {
+ this.removeApp()
+ }
// Remove the map param from the URL using regex to prevent encoding
const key = this.config.mapViewParamKey
const href = location.href
@@ -190,6 +200,52 @@ export default class InteractiveMap {
this.eventBus.emit(events.MAP_DESTROY, { mapId: this.id })
}
+ /**
+ * Hide the map application without destroying it (preserves state).
+ * Used in hybrid mode when resizing below breakpoint.
+ *
+ * @internal Not intended for end-user use.
+ */
+ hideApp () {
+ this._isHidden = true
+ this.rootEl.style.display = 'none'
+
+ // Restore inert elements before focusing button
+ toggleInertElements({ containerEl: this.rootEl, isFullscreen: false })
+
+ if (this._openButton) {
+ this._openButton.removeAttribute('style')
+ this._openButton.focus()
+ }
+
+ // Remove fullscreen classes
+ document.documentElement.classList.remove('im-is-fullscreen')
+ this.rootEl.classList.remove('im-is-fullscreen')
+
+ // Reset page title (remove prepended map title)
+ const parts = document.title.split(': ')
+ if (parts.length > 1) {
+ document.title = parts[parts.length - 1]
+ }
+ }
+
+ /**
+ * Show a previously hidden map application.
+ * Used in hybrid mode when resizing above breakpoint or clicking button.
+ *
+ * @internal Not intended for end-user use.
+ */
+ showApp () {
+ this._isHidden = false
+ this.rootEl.style.display = ''
+
+ if (this._openButton) {
+ this._openButton.style.display = 'none'
+ }
+
+ updateDOMState(this)
+ }
+
/**
* Destroy the map instance and clean up all resources.
*
diff --git a/src/InteractiveMap/InteractiveMap.test.js b/src/InteractiveMap/InteractiveMap.test.js
index d08882ef..5411dcf4 100755
--- a/src/InteractiveMap/InteractiveMap.test.js
+++ b/src/InteractiveMap/InteractiveMap.test.js
@@ -268,20 +268,23 @@ describe('InteractiveMap Core Functionality', () => {
expect(() => map.destroy()).not.toThrow()
})
- it('_handleExitClick removes app and calls replaceState', () => {
+ it('_handleExitClick removes app when preserveStateOnClose is false', () => {
const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {})
const map = new InteractiveMap('map', {
behaviour: 'buttonFirst',
mapProvider: mapProviderMock,
- mapViewParamKey: 'mv'
+ mapViewParamKey: 'mv',
+ preserveStateOnClose: false
})
const removeAppSpy = jest.spyOn(map, 'removeApp').mockImplementation(() => {})
+ const hideAppSpy = jest.spyOn(map, 'hideApp').mockImplementation(() => {})
map._handleExitClick()
expect(removeAppSpy).toHaveBeenCalled()
+ expect(hideAppSpy).not.toHaveBeenCalled()
expect(replaceStateSpy).toHaveBeenCalledWith(
history.state,
'',
@@ -289,8 +292,78 @@ describe('InteractiveMap Core Functionality', () => {
)
removeAppSpy.mockRestore()
+ hideAppSpy.mockRestore()
replaceStateSpy.mockRestore()
})
+
+ it('_handleExitClick hides app when preserveStateOnClose is true', () => {
+ const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {})
+
+ const map = new InteractiveMap('map', {
+ behaviour: 'buttonFirst',
+ mapProvider: mapProviderMock,
+ mapViewParamKey: 'mv',
+ preserveStateOnClose: true
+ })
+
+ const removeAppSpy = jest.spyOn(map, 'removeApp').mockImplementation(() => {})
+ const hideAppSpy = jest.spyOn(map, 'hideApp').mockImplementation(() => {})
+
+ map._handleExitClick()
+
+ expect(hideAppSpy).toHaveBeenCalled()
+ expect(removeAppSpy).not.toHaveBeenCalled()
+ expect(replaceStateSpy).toHaveBeenCalled()
+
+ removeAppSpy.mockRestore()
+ hideAppSpy.mockRestore()
+ replaceStateSpy.mockRestore()
+ })
+
+ it('_handleButtonClick calls showApp when map is hidden', async () => {
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
+ map._isHidden = true
+ const showAppSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {})
+ const loadAppSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
+ const pushStateSpy = jest.spyOn(history, 'pushState').mockImplementation(() => {})
+ const fakeEvent = { currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } }
+
+ await openButtonCallback(fakeEvent)
+
+ expect(showAppSpy).toHaveBeenCalled()
+ expect(loadAppSpy).not.toHaveBeenCalled()
+ expect(pushStateSpy).toHaveBeenCalled()
+
+ showAppSpy.mockRestore()
+ loadAppSpy.mockRestore()
+ pushStateSpy.mockRestore()
+ })
+
+ it('hideApp sets _isHidden and hides element', () => {
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
+ map._openButton = mockButtonInstance
+
+ map.hideApp()
+
+ expect(map._isHidden).toBe(true)
+ expect(map.rootEl.style.display).toBe('none')
+ expect(mockButtonInstance.removeAttribute).toHaveBeenCalledWith('style')
+ expect(mockButtonInstance.focus).toHaveBeenCalled()
+ })
+
+ it('showApp sets _isHidden false and shows element', () => {
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
+ map._isHidden = true
+ map._openButton = mockButtonInstance
+ map.rootEl.style.display = 'none'
+
+ map.showApp()
+
+ expect(map._isHidden).toBe(false)
+ expect(map.rootEl.style.display).toBe('')
+ expect(mockButtonInstance.style.display).toBe('none')
+ expect(updateDOMState).toHaveBeenCalledWith(map)
+ })
})
describe('InteractiveMap Public API Methods', () => {
diff --git a/src/InteractiveMap/behaviourController.js b/src/InteractiveMap/behaviourController.js
index dd45758d..0ca44b07 100755
--- a/src/InteractiveMap/behaviourController.js
+++ b/src/InteractiveMap/behaviourController.js
@@ -1,5 +1,6 @@
import { getQueryParam } from '../utils/queryString.js'
import { isHybridFullscreen } from '../utils/getIsFullscreen.js'
+import { updateDOMState } from './domStateManager.js'
import defaults from '../config/defaults.js'
// -----------------------------------------------------------------------------
@@ -56,9 +57,18 @@ function setupBehavior (mapInstance) {
const handleChange = () => {
if (shouldLoadComponent(mapInstance.config)) {
- mapInstance.loadApp()
+ if (mapInstance._isHidden) {
+ mapInstance.showApp()
+ } else if (mapInstance._root == null) {
+ mapInstance.loadApp()
+ } else {
+ // Map is showing - update DOM state for fullscreen/inline transition
+ updateDOMState(mapInstance)
+ }
+ } else if (mapInstance._root) {
+ mapInstance.hideApp()
} else {
- mapInstance.removeApp()
+ // No action
}
}
diff --git a/src/InteractiveMap/behaviourController.test.js b/src/InteractiveMap/behaviourController.test.js
index 1d9b0314..033af75c 100755
--- a/src/InteractiveMap/behaviourController.test.js
+++ b/src/InteractiveMap/behaviourController.test.js
@@ -4,8 +4,10 @@
import { setupBehavior, shouldLoadComponent } from './behaviourController.js'
import * as queryString from '../utils/queryString.js'
+import { updateDOMState } from './domStateManager.js'
jest.mock('../utils/queryString.js')
+jest.mock('./domStateManager.js', () => ({ updateDOMState: jest.fn() }))
describe('shouldLoadComponent', () => {
beforeEach(() => {
@@ -60,8 +62,12 @@ describe('setupBehavior', () => {
mockMapInstance = {
config: { hybridWidth: null, maxMobileWidth: 640 },
_breakpointDetector: mockBreakpointDetector,
+ _root: null,
+ _isHidden: false,
loadApp: jest.fn(),
- removeApp: jest.fn()
+ removeApp: jest.fn(),
+ hideApp: jest.fn(),
+ showApp: jest.fn()
}
// Default: viewport is wide
window.matchMedia = jest.fn().mockImplementation(() => ({
@@ -145,4 +151,63 @@ describe('setupBehavior', () => {
const cleanup = setupBehavior(mockMapInstance)
expect(cleanup).toBeNull()
})
+
+ describe('hybrid behaviour handleChange', () => {
+ let handleChange
+
+ beforeEach(() => {
+ const mockAddEventListener = jest.fn((event, cb) => { handleChange = cb })
+ window.matchMedia = jest.fn().mockImplementation(() => ({
+ matches: false,
+ addEventListener: mockAddEventListener,
+ removeEventListener: jest.fn()
+ }))
+ mockMapInstance.config = { id: 'test', behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640 }
+ setupBehavior(mockMapInstance)
+ })
+
+ it('calls showApp when map is hidden and should load', () => {
+ mockMapInstance._isHidden = true
+ queryString.getQueryParam.mockReturnValue(null) // wide viewport, should load
+
+ handleChange()
+
+ expect(mockMapInstance.showApp).toHaveBeenCalled()
+ expect(mockMapInstance.loadApp).not.toHaveBeenCalled()
+ })
+
+ it('calls loadApp when map has no root and should load', () => {
+ mockMapInstance._isHidden = false
+ mockMapInstance._root = null
+ queryString.getQueryParam.mockReturnValue(null)
+
+ handleChange()
+
+ expect(mockMapInstance.loadApp).toHaveBeenCalled()
+ expect(mockMapInstance.showApp).not.toHaveBeenCalled()
+ })
+
+ it('calls updateDOMState when map is showing and should load', () => {
+ mockMapInstance._isHidden = false
+ mockMapInstance._root = {} // has root
+ queryString.getQueryParam.mockReturnValue(null)
+
+ handleChange()
+
+ expect(updateDOMState).toHaveBeenCalledWith(mockMapInstance)
+ expect(mockMapInstance.loadApp).not.toHaveBeenCalled()
+ expect(mockMapInstance.showApp).not.toHaveBeenCalled()
+ })
+
+ it('calls hideApp when should not load and has root', () => {
+ mockMapInstance._root = {}
+ // Simulate narrow viewport where shouldLoadComponent returns false
+ window.matchMedia = jest.fn().mockImplementation(() => ({ matches: true }))
+ queryString.getQueryParam.mockReturnValue(null)
+
+ handleChange()
+
+ expect(mockMapInstance.hideApp).toHaveBeenCalled()
+ })
+ })
})
diff --git a/src/InteractiveMap/historyManager.js b/src/InteractiveMap/historyManager.js
index 0330dae9..baca10d8 100755
--- a/src/InteractiveMap/historyManager.js
+++ b/src/InteractiveMap/historyManager.js
@@ -6,6 +6,40 @@ import defaults from '../config/defaults.js'
// Internal helpers
// -----------------------------------------------------------------------------
+/**
+ * Opens the map application for a given map instance.
+ *
+ * If the map instance was previously hidden, it restores the existing app.
+ * Otherwise, it loads the app for the first time.
+ *
+ * @param {MapInstance} mapInstance
+ * The map instance whose application should be opened.
+ */
+function openMap (mapInstance) {
+ if (mapInstance._isHidden) {
+ mapInstance.showApp?.()
+ } else {
+ mapInstance.loadApp?.()
+ }
+}
+
+/**
+ * Closes the map application for a given map instance.
+ *
+ * Depending on the configuration, the map state is either preserved
+ * by hiding the app or fully removed from the DOM.
+ *
+ * @param {MapInstance} mapInstance
+ * The map instance whose application should be closed.
+ */
+function closeMap (mapInstance) {
+ if (mapInstance.config.preserveStateOnClose) {
+ mapInstance.hideApp?.()
+ } else {
+ mapInstance.removeApp?.()
+ }
+}
+
/**
* Handles the `popstate` event triggered by browser back/forward navigation.
*
@@ -28,11 +62,13 @@ function handlePopstate () {
const isHybridVisible = mapInstance.config.behaviour === 'hybrid' && !isHybridFullscreen(mapInstance.config)
const isOpen = mapInstance.rootEl?.children.length
- if (shouldBeOpen && !isOpen) {
- mapInstance.loadApp?.()
- } else if (!shouldBeOpen && isOpen && !isHybridVisible) {
- mapInstance.removeApp?.()
- mapInstance.openButton?.focus?.()
+ if (shouldBeOpen && (!isOpen || mapInstance._isHidden)) {
+ openMap(mapInstance)
+ continue
+ }
+
+ if (!shouldBeOpen && isOpen && !isHybridVisible) {
+ closeMap(mapInstance)
}
}
}
diff --git a/src/InteractiveMap/historyManager.test.js b/src/InteractiveMap/historyManager.test.js
index f3c94e6c..2a340009 100755
--- a/src/InteractiveMap/historyManager.test.js
+++ b/src/InteractiveMap/historyManager.test.js
@@ -13,19 +13,25 @@ describe('historyManager', () => {
beforeEach(() => {
component1 = {
id: 'map',
- config: { behaviour: 'buttonFirst', hybridWidth: null, maxMobileWidth: 640 },
+ config: { behaviour: 'buttonFirst', hybridWidth: null, maxMobileWidth: 640, preserveStateOnClose: false },
rootEl: document.createElement('div'),
loadApp: jest.fn(),
removeApp: jest.fn(),
- openButton: { focus: jest.fn() }
+ hideApp: jest.fn(),
+ showApp: jest.fn(),
+ openButton: { focus: jest.fn() },
+ _isHidden: false
}
component2 = {
id: 'list',
- config: { behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640 },
+ config: { behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640, preserveStateOnClose: false },
rootEl: document.createElement('div'),
loadApp: jest.fn(),
removeApp: jest.fn(),
- openButton: { focus: jest.fn() }
+ hideApp: jest.fn(),
+ showApp: jest.fn(),
+ openButton: { focus: jest.fn() },
+ _isHidden: false
}
popstateEvent = new PopStateEvent('popstate')
jest.clearAllMocks()
@@ -63,7 +69,8 @@ describe('historyManager', () => {
expect(component1.loadApp).not.toHaveBeenCalled()
})
- it('removes component and focuses button when view param does not match', () => {
+ it('removes component when view param does not match and preserveStateOnClose is false', () => {
+ component1.config.preserveStateOnClose = false
component1.rootEl.appendChild(document.createElement('div'))
historyManager.register(component1)
queryString.getQueryParam.mockReturnValue(null)
@@ -71,7 +78,19 @@ describe('historyManager', () => {
window.dispatchEvent(popstateEvent)
expect(component1.removeApp).toHaveBeenCalled()
- expect(component1.openButton.focus).toHaveBeenCalled()
+ expect(component1.hideApp).not.toHaveBeenCalled()
+ })
+
+ it('hides component when view param does not match and preserveStateOnClose is true', () => {
+ component1.config.preserveStateOnClose = true
+ component1.rootEl.appendChild(document.createElement('div'))
+ historyManager.register(component1)
+ queryString.getQueryParam.mockReturnValue(null)
+
+ window.dispatchEvent(popstateEvent)
+
+ expect(component1.hideApp).toHaveBeenCalled()
+ expect(component1.removeApp).not.toHaveBeenCalled()
})
it('does not remove hybrid component when viewport is wide (inline mode)', () => {
@@ -87,6 +106,7 @@ describe('historyManager', () => {
})
it('removes hybrid component when viewport is narrow and view does not match', () => {
+ component2.config.preserveStateOnClose = false
component2.rootEl.appendChild(document.createElement('div'))
historyManager.register(component2)
queryString.getQueryParam.mockReturnValue(null)
@@ -98,6 +118,17 @@ describe('historyManager', () => {
expect(component2.removeApp).toHaveBeenCalled()
})
+ it('calls showApp when view param matches and component is hidden', () => {
+ component1._isHidden = true
+ historyManager.register(component1)
+ queryString.getQueryParam.mockReturnValue('map')
+
+ window.dispatchEvent(popstateEvent)
+
+ expect(component1.showApp).toHaveBeenCalled()
+ expect(component1.loadApp).not.toHaveBeenCalled()
+ })
+
it('uses hybridWidth for media query when provided', () => {
component2.config.hybridWidth = 768
component2.rootEl.appendChild(document.createElement('div'))
diff --git a/src/config/appConfig.js b/src/config/appConfig.js
index aadf4bdc..a1ae97e9 100755
--- a/src/config/appConfig.js
+++ b/src/config/appConfig.js
@@ -18,12 +18,12 @@ const exitButtonSlots = {
}
// Default app buttons, panels and icons
-export const appConfig = {
+export const defaultAppConfig = {
buttons: [{
id: 'exit',
label: 'Exit',
iconId: 'close',
- onClick: (e, { services }) => services.closeApp(),
+ onClick: (_e, { services }) => services.closeApp(),
excludeWhen: ({ appConfig, appState }) => !appConfig.hasExitButton || !(appState.isFullscreen && (new URL(window.location.href)).searchParams.has(appConfig.mapViewParamKey)),
mobile: exitButtonSlots,
tablet: exitButtonSlots,
@@ -32,7 +32,7 @@ export const appConfig = {
id: 'fullscreen',
label: () => `${document.fullscreenElement ? 'Exit' : 'Enter'} fullscreen`,
iconId: () => document.fullscreenElement ? 'minimise' : 'maximise',
- onClick: (e, { appState }) => {
+ onClick: (_e, { appState }) => {
const container = appState.layoutRefs.appContainerRef.current
document.fullscreenElement ? document.exitFullscreen() : container.requestFullscreen()
},
@@ -45,7 +45,7 @@ export const appConfig = {
group: 'zoom',
label: 'Zoom in',
iconId: 'plus',
- onClick: (e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
+ onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch',
enableWhen: ({ mapState }) => !mapState.isAtMaxZoom,
mobile: buttonSlots,
@@ -56,7 +56,7 @@ export const appConfig = {
group: 'zoom',
label: 'Zoom out',
iconId: 'minus',
- onClick: (e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
+ onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch',
enableWhen: ({ mapState }) => !mapState.isAtMinZoom,
mobile: buttonSlots,
diff --git a/src/config/appConfig.test.js b/src/config/appConfig.test.js
index e137ef9a..0c821fb5 100755
--- a/src/config/appConfig.test.js
+++ b/src/config/appConfig.test.js
@@ -1,14 +1,14 @@
import { render } from '@testing-library/react'
-import { appConfig } from './appConfig'
+import { defaultAppConfig } from './appConfig'
-describe('appConfig', () => {
+describe('defaultAppConfig', () => {
const appState = { layoutRefs: { appContainerRef: { current: document.createElement('div') } }, isFullscreen: false }
- const buttons = appConfig.buttons
+ const buttons = defaultAppConfig.buttons
const fullscreenBtn = buttons.find(b => b.id === 'fullscreen')
const exitBtn = buttons.find(b => b.id === 'exit')
it('renders KeyboardHelp panel', () => {
- const panel = appConfig.panels.find(p => p.id === 'keyboardHelp')
+ const panel = defaultAppConfig.panels.find(p => p.id === 'keyboardHelp')
const { container } = render(panel.render())
expect(container.querySelector('.im-c-keyboard-help')).toBeInTheDocument()
})
@@ -16,11 +16,11 @@ describe('appConfig', () => {
it('evaluates dynamic button properties', () => {
// label
expect(typeof fullscreenBtn.label).toBe('function')
- expect(fullscreenBtn.label({ appState, appConfig })).toMatch(/fullscreen/)
+ expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toMatch(/fullscreen/)
// iconId
expect(typeof fullscreenBtn.iconId).toBe('function')
- expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('maximise')
+ expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('maximise')
// excludeWhen
expect(exitBtn.excludeWhen({ appConfig: { hasExitButton: false } })).toBe(true)
@@ -50,13 +50,13 @@ describe('appConfig', () => {
// Not in fullscreen
Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true })
- expect(fullscreenBtn.label({ appState, appConfig })).toBe('Enter fullscreen')
- expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('maximise')
+ expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toBe('Enter fullscreen')
+ expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('maximise')
// In fullscreen
Object.defineProperty(document, 'fullscreenElement', { value: containerMock, writable: true })
- expect(fullscreenBtn.label({ appState, appConfig })).toBe('Exit fullscreen')
- expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('minimise')
+ expect(fullscreenBtn.label({ appState, appConfig: defaultAppConfig })).toBe('Exit fullscreen')
+ expect(fullscreenBtn.iconId({ appState, appConfig: defaultAppConfig })).toBe('minimise')
})
it('calls exit button onClick correctly', () => {
diff --git a/src/config/defaults.js b/src/config/defaults.js
index 5978516e..c956661d 100755
--- a/src/config/defaults.js
+++ b/src/config/defaults.js
@@ -33,6 +33,7 @@ const defaults = {
nudgeZoomDelta: 0.1,
panDelta: 100,
pageTitle: 'Map view',
+ preserveStateOnClose: false,
readMapText: false,
reverseGeocodeProvider: null,
zoomDelta: 1
diff --git a/src/types.js b/src/types.js
index 148cb14b..4dae6abd 100644
--- a/src/types.js
+++ b/src/types.js
@@ -568,6 +568,11 @@
* @property {PluginDescriptor[]} [plugins]
* Plugins to load.
*
+ * @property {boolean} [preserveStateOnClose=false]
+ * Whether to preserve the map state when closed via back button or exit button.
+ * When true, the map is hidden but not destroyed, preserving markers, zoom, etc.
+ * Useful for list/map toggle scenarios. Only applies to 'hybrid' and 'buttonFirst' behaviours.
+ *
* @property {boolean} [readMapText=false]
* Whether map text labels can be selected and read aloud by assistive technologies.
*
diff --git a/src/utils/detectBreakpoint.js b/src/utils/detectBreakpoint.js
index 8341ff5a..9106b02f 100755
--- a/src/utils/detectBreakpoint.js
+++ b/src/utils/detectBreakpoint.js
@@ -1,106 +1,107 @@
-function createBreakpointDetector ({ maxMobileWidth, minDesktopWidth, containerEl }) {
- let lastBreakpoint = 'unknown'
- const listeners = new Set()
- let cleanup = null
-
- const getBreakpointType = (width) => {
- if (width <= maxMobileWidth) {
- return 'mobile'
- }
- if (width >= minDesktopWidth) {
- return 'desktop'
- }
- return 'tablet'
+function getBreakpointType (width, maxMobileWidth, minDesktopWidth) {
+ if (width <= maxMobileWidth) {
+ return 'mobile'
}
-
- const notifyListeners = (type) => {
- if (type !== lastBreakpoint) {
- lastBreakpoint = type // Set synchronously BEFORE RAF
- requestAnimationFrame(() => {
- // Double-check it hasn't changed again
- if (lastBreakpoint === type) {
- listeners.forEach(fn => fn(type))
- }
- })
- }
+ if (width >= minDesktopWidth) {
+ return 'desktop'
}
+ return 'tablet'
+}
- // Container-based detection
- if (containerEl) {
- containerEl.style.containerType = 'inline-size'
-
- // Set initial detection BEFORE observing to prevent double notification
- const initialWidth = containerEl.getBoundingClientRect().width
- const initialType = getBreakpointType(initialWidth)
- containerEl.setAttribute('data-breakpoint', initialType)
- lastBreakpoint = initialType // Set this directly, don't notify yet
+function createContainerDetector (containerEl, getType, notifyListeners) {
+ containerEl.style.containerType = 'inline-size'
- const observer = new ResizeObserver((entries) => {
- const width = entries[0]?.borderBoxSize?.[0]?.inlineSize || entries[0]?.contentRect.width
- const type = getBreakpointType(width)
- containerEl.setAttribute('data-breakpoint', type)
- notifyListeners(type)
- })
+ const initialWidth = containerEl.getBoundingClientRect().width
+ const initialType = getType(initialWidth)
+ containerEl.dataset.breakpoint = initialType
- observer.observe(containerEl)
+ const observer = new ResizeObserver((entries) => {
+ const width = entries[0]?.borderBoxSize?.[0]?.inlineSize || entries[0]?.contentRect.width
+ const type = getType(width)
+ containerEl.dataset.breakpoint = type
+ notifyListeners(type)
+ })
- // Now notify listeners after observer is set up
- notifyListeners(initialType)
+ observer.observe(containerEl)
- cleanup = () => {
+ return {
+ initialType,
+ cleanup: () => {
observer.disconnect()
containerEl.style.containerType = ''
- containerEl.removeAttribute('data-breakpoint')
+ delete containerEl.dataset.breakpoint
}
- } else {
- // Viewport-based fallback
- const mq = {
- mobile: window.matchMedia(`(max-width: ${maxMobileWidth}px)`),
- desktop: window.matchMedia(`(min-width: ${minDesktopWidth}px)`)
- }
-
- const detect = () => {
- let type
+ }
+}
- if (mq.mobile.matches) {
- type = 'mobile'
- } else if (mq.desktop.matches) {
- type = 'desktop'
- } else {
- type = 'tablet'
- }
+function createViewportDetector (maxMobileWidth, minDesktopWidth, notifyListeners) {
+ const mq = {
+ mobile: window.matchMedia(`(max-width: ${maxMobileWidth}px)`),
+ desktop: window.matchMedia(`(min-width: ${minDesktopWidth}px)`)
+ }
- notifyListeners(type)
+ const detect = () => {
+ let type = 'tablet'
+ if (mq.mobile.matches) {
+ type = 'mobile'
+ } else if (mq.desktop.matches) {
+ type = 'desktop'
+ } else {
+ // No action
}
+ notifyListeners(type)
+ }
- mq.mobile.addEventListener('change', detect)
- mq.desktop.addEventListener('change', detect)
- detect()
+ mq.mobile.addEventListener('change', detect)
+ mq.desktop.addEventListener('change', detect)
+ detect()
- cleanup = () => {
+ return {
+ cleanup: () => {
mq.mobile.removeEventListener('change', detect)
mq.desktop.removeEventListener('change', detect)
}
}
+}
- const subscribe = (fn) => {
- listeners.add(fn)
- return () => listeners.delete(fn)
- }
+function createBreakpointDetector ({ maxMobileWidth, minDesktopWidth, containerEl }) {
+ let lastBreakpoint = 'unknown'
+ const listeners = new Set()
- const getBreakpoint = () => {
- return lastBreakpoint === 'unknown' ? 'desktop' : lastBreakpoint
+ const notifyListeners = (type) => {
+ if (type !== lastBreakpoint) {
+ lastBreakpoint = type
+ requestAnimationFrame(() => {
+ if (lastBreakpoint === type) {
+ listeners.forEach(fn => fn(type))
+ }
+ })
+ }
}
- const destroy = () => {
- cleanup?.()
- listeners.clear()
+ const getType = (width) => getBreakpointType(width, maxMobileWidth, minDesktopWidth)
+
+ let cleanup
+ if (containerEl) {
+ const detector = createContainerDetector(containerEl, getType, notifyListeners)
+ lastBreakpoint = detector.initialType
+ notifyListeners(detector.initialType)
+ cleanup = detector.cleanup
+ } else {
+ const detector = createViewportDetector(maxMobileWidth, minDesktopWidth, notifyListeners)
+ cleanup = detector.cleanup
}
return {
- subscribe,
- getBreakpoint,
- destroy
+ subscribe: (fn) => {
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+ },
+ getBreakpoint: () => lastBreakpoint,
+ destroy: () => {
+ cleanup?.()
+ listeners.clear()
+ }
}
}