From 2872c248ba38b63e868446233f8ae24fa9023e2a Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 11 Feb 2026 17:20:16 +0000 Subject: [PATCH] Sonar cloud fixes --- providers/maplibre/src/defaults.js | 5 +- providers/maplibre/src/maplibreProvider.js | 16 +- .../maplibre/src/utils/highlightFeatures.js | 122 ++++++++-------- providers/maplibre/src/utils/maplibreFixes.js | 4 +- src/App/hooks/useKeyboardShortcuts.js | 6 +- src/App/hooks/useMapProviderOverrides.js | 6 +- src/App/hooks/useMarkersAPI.js | 138 +++++++++++------- src/App/hooks/useModalPanelBehaviour.js | 86 ++++++----- src/App/initialiseApp.js | 79 +++++----- src/App/renderer/mapButtons.js | 7 +- src/utils/toggleInertElements.js | 49 +++++-- 11 files changed, 296 insertions(+), 222 deletions(-) diff --git a/providers/maplibre/src/defaults.js b/providers/maplibre/src/defaults.js index 06a27b73..8bcc823b 100755 --- a/providers/maplibre/src/defaults.js +++ b/providers/maplibre/src/defaults.js @@ -1,5 +1,6 @@ -export const defaults = { - animationDuration: 400 // Must be less than core debounce time (500ms) +export const DEFAULTS = { + animationDuration: 400, // Must be less than core debounce time (500ms) + coordinatePrecision: 7 } export const supportedShortcuts = [ diff --git a/providers/maplibre/src/maplibreProvider.js b/providers/maplibre/src/maplibreProvider.js index def8d5e0..d4efed42 100755 --- a/providers/maplibre/src/maplibreProvider.js +++ b/providers/maplibre/src/maplibreProvider.js @@ -3,7 +3,7 @@ * @typedef {import('../../../src/types.js').MapProviderConfig} MapProviderConfig */ -import { defaults, supportedShortcuts } from './defaults.js' +import { DEFAULTS, supportedShortcuts } from './defaults.js' import { cleanCanvas, applyPreventDefaultFix } from './utils/maplibreFixes.js' import { attachMapEvents } from './mapEvents.js' import { attachAppEvents } from './appEvents.js' @@ -129,7 +129,7 @@ export default class MapLibreProvider { this.map.flyTo({ center: center || this.getCenter(), zoom: zoom || this.getZoom(), - duration: defaults.animationDuration + duration: DEFAULTS.animationDuration }) } @@ -141,7 +141,7 @@ export default class MapLibreProvider { zoomIn (zoomDelta) { this.map.easeTo({ zoom: this.getZoom() + zoomDelta, - duration: defaults.animationDuration + duration: DEFAULTS.animationDuration }) } @@ -153,7 +153,7 @@ export default class MapLibreProvider { zoomOut (zoomDelta) { this.map.easeTo({ zoom: this.getZoom() - zoomDelta, - duration: defaults.animationDuration + duration: DEFAULTS.animationDuration }) } @@ -163,7 +163,7 @@ export default class MapLibreProvider { * @param {[number, number]} offset - Pixel offset [x, y]. */ panBy (offset) { - this.map.panBy(offset, { duration: defaults.animationDuration }) + this.map.panBy(offset, { duration: DEFAULTS.animationDuration }) } /** @@ -172,7 +172,7 @@ export default class MapLibreProvider { * @param {[number, number, number, number]} bounds - Bounds as [west, south, east, north]. */ fitToBounds (bounds) { - this.map.fitBounds(bounds, { duration: defaults.animationDuration }) + this.map.fitBounds(bounds, { duration: DEFAULTS.animationDuration }) } /** @@ -241,7 +241,7 @@ export default class MapLibreProvider { */ getCenter () { const coord = this.map.getCenter() - return [Number(coord.lng.toFixed(7)), Number(coord.lat.toFixed(7))] + return [Number(coord.lng.toFixed(DEFAULTS.coordinatePrecision)), Number(coord.lat.toFixed(DEFAULTS.coordinatePrecision))] } /** @@ -250,7 +250,7 @@ export default class MapLibreProvider { * @returns {number} */ getZoom () { - return Number(this.map.getZoom().toFixed(7)) + return Number(this.map.getZoom().toFixed(DEFAULTS.coordinatePrecision)) } /** diff --git a/providers/maplibre/src/utils/highlightFeatures.js b/providers/maplibre/src/utils/highlightFeatures.js index 313a1a61..3260731a 100755 --- a/providers/maplibre/src/utils/highlightFeatures.js +++ b/providers/maplibre/src/utils/highlightFeatures.js @@ -1,16 +1,6 @@ -/** - * Update highlighted features using pure filters. - * Supports fill + line geometry, multi-source, cleanup, and bounds. - */ -function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, stylesMap }) { - if (!map) { - return null - } - +const groupFeaturesBySource = (map, selectedFeatures) => { const featuresBySource = {} - const renderedFeatures = [] - // Group features by source selectedFeatures?.forEach(({ featureId, layerId, idProperty, geometry }) => { const layer = map.getLayer(layerId) @@ -37,10 +27,10 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles featuresBySource[sourceId].ids.add(featureId) }) - const currentSources = new Set(Object.keys(featuresBySource)) - const previousSources = map._highlightedSources || new Set() + return featuresBySource +} - // Cleanup for sources no longer selected +const cleanupStaleSources = (map, previousSources, currentSources) => { previousSources.forEach(src => { if (!currentSources.has(src)) { const base = `highlight-${src}` @@ -52,7 +42,55 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles }) } }) +} + +const applyHighlightLayer = (map, id, type, sourceId, srcLayer, paint, filter) => { + if (!map.getLayer(id)) { + map.addLayer({ + id, + type, + source: sourceId, + ...(srcLayer && { 'source-layer': srcLayer }), + paint + }) + } + Object.entries(paint).forEach(([prop, value]) => { + map.setPaintProperty(id, prop, value) + }) + map.setFilter(id, filter) +} + +const calculateBounds = (LngLatBounds, renderedFeatures) => { + if (!renderedFeatures.length) { + return null + } + + const bounds = new LngLatBounds() + + renderedFeatures.forEach(f => { + const add = (c) => typeof c[0] === 'number' ? bounds.extend(c) : c.forEach(add) + add(f.geometry.coordinates) + }) + + return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()] +} +/** + * Update highlighted features using pure filters. + * Supports fill + line geometry, multi-source, cleanup, and bounds. + */ +export function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, stylesMap }) { + if (!map) { + return null + } + + const featuresBySource = groupFeaturesBySource(map, selectedFeatures) + const renderedFeatures = [] + + const currentSources = new Set(Object.keys(featuresBySource)) + const previousSources = map._highlightedSources || new Set() + + cleanupStaleSources(map, previousSources, currentSources) map._highlightedSources = currentSources // Apply highlights for current sources @@ -70,31 +108,11 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles const idExpression = idProperty ? ['get', idProperty] : ['id'] const filter = ['in', idExpression, ['literal', [...ids]]] - // Ensure layers + const linePaint = { 'line-color': stroke, 'line-width': strokeWidth } + if (geom === 'fill') { - if (!map.getLayer(`${base}-fill`)) { - map.addLayer({ - id: `${base}-fill`, - type: 'fill', - source: sourceId, - ...(srcLayer && { 'source-layer': srcLayer }), - paint: { 'fill-color': fill } - }) - } - map.setPaintProperty(`${base}-fill`, 'fill-color', fill) - map.setFilter(`${base}-fill`, filter) - if (!map.getLayer(`${base}-line`)) { - map.addLayer({ - id: `${base}-line`, - type: 'line', - source: sourceId, - ...(srcLayer && { 'source-layer': srcLayer }), - paint: { 'line-color': stroke, 'line-width': strokeWidth } - }) - } - map.setPaintProperty(`${base}-line`, 'line-color', stroke) - map.setPaintProperty(`${base}-line`, 'line-width', strokeWidth) - map.setFilter(`${base}-line`, filter) + applyHighlightLayer(map, `${base}-fill`, 'fill', sourceId, srcLayer, { 'fill-color': fill }, filter) + applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, linePaint, filter) } if (geom === 'line') { @@ -102,18 +120,7 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles if (map.getLayer(`${base}-fill`)) { map.setFilter(`${base}-fill`, ['==', 'id', '']) } - if (!map.getLayer(`${base}-line`)) { - map.addLayer({ - id: `${base}-line`, - type: 'line', - source: sourceId, - ...(srcLayer && { 'source-layer': srcLayer }), - paint: { 'line-color': stroke, 'line-width': strokeWidth } - }) - } - map.setPaintProperty(`${base}-line`, 'line-color', stroke) - map.setPaintProperty(`${base}-line`, 'line-width', strokeWidth) - map.setFilter(`${base}-line`, filter) + applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, linePaint, filter) } // Bounds only from rendered tiles @@ -124,18 +131,5 @@ function updateHighlightedFeatures({ LngLatBounds, map, selectedFeatures, styles ) }) - // Calculate bounds - if (!renderedFeatures.length) { - return null - } - - let bounds = new LngLatBounds() - renderedFeatures.forEach(f => { - const add = (c) => typeof c[0] === 'number' ? bounds.extend(c) : c.forEach(add) - add(f.geometry.coordinates) - }) - - return [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()] + return calculateBounds(LngLatBounds, renderedFeatures) } - -export { updateHighlightedFeatures } diff --git a/providers/maplibre/src/utils/maplibreFixes.js b/providers/maplibre/src/utils/maplibreFixes.js index 8e46714f..33d375bb 100755 --- a/providers/maplibre/src/utils/maplibreFixes.js +++ b/providers/maplibre/src/utils/maplibreFixes.js @@ -13,7 +13,7 @@ export function applyPreventDefaultFix (map) { const originalPreventDefault = Event.prototype.preventDefault // Override preventDefault only for events targeting our map - Event.prototype.preventDefault = function () { + Event.prototype.preventDefault = function () { // NOSONAR: intentional monkey-patch to fix MapLibre touch event bug if ((this.type === 'touchmove' || this.type === 'touchstart') && !this.cancelable) { // Check if the event target is within our map container const canvas = map.getCanvas() @@ -21,6 +21,6 @@ export function applyPreventDefaultFix (map) { return } } - return originalPreventDefault.call(this) + originalPreventDefault.call(this) } } diff --git a/src/App/hooks/useKeyboardShortcuts.js b/src/App/hooks/useKeyboardShortcuts.js index 83580142..9f52c209 100755 --- a/src/App/hooks/useKeyboardShortcuts.js +++ b/src/App/hooks/useKeyboardShortcuts.js @@ -13,7 +13,7 @@ export function useKeyboardShortcuts (containerRef) { useEffect(() => { const el = containerRef.current if (!el || interfaceType !== 'keyboard') { - return + return undefined } const actions = createKeyboardActions(mapProvider, announce, { @@ -31,7 +31,7 @@ export function useKeyboardShortcuts (containerRef) { // Use e.code for letters to avoid 'dead' keys with Alt/AltGr if (/^Key[A-Z]$/.test(e.code)) { - key = e.code.slice(3) // "KeyI" -> "I" + key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I" } else { key = e.key // works for arrows, numpad, punctuation } @@ -41,6 +41,8 @@ export function useKeyboardShortcuts (containerRef) { key = '+' } else if (key === 'Subtract' || key === 'NumpadSubtract') { key = '-' + } else { + // No action } return e.altKey ? `Alt+${key}` : key diff --git a/src/App/hooks/useMapProviderOverrides.js b/src/App/hooks/useMapProviderOverrides.js index 7784266f..6ba654ff 100755 --- a/src/App/hooks/useMapProviderOverrides.js +++ b/src/App/hooks/useMapProviderOverrides.js @@ -32,14 +32,14 @@ export const useMapProviderOverrides = () => { useEffect(() => { if (!mapProvider) { - return + return undefined } const originalFitToBounds = mapProvider.fitToBounds mapProvider.fitToBounds = (bounds, skipPaddingCalc = false) => { if (!bounds) { - return + return undefined } // Calculate and set safe zone padding unless explicitly skipped @@ -54,7 +54,7 @@ export const useMapProviderOverrides = () => { mapProvider.setView = ({ center, zoom }) => { if (!center) { - return + return undefined } updatePadding() diff --git a/src/App/hooks/useMarkersAPI.js b/src/App/hooks/useMarkersAPI.js index 8aad3ad6..71dff898 100755 --- a/src/App/hooks/useMarkersAPI.js +++ b/src/App/hooks/useMarkersAPI.js @@ -5,22 +5,101 @@ import { useService } from '../store/serviceContext.js' import { scaleFactor } from '../../config/appConfig.js' import { EVENTS as events } from '../../config/events.js' -// Pure function - easier to test +// Vertical offset to align the marker tip with the coordinate point +const MARKER_ANCHOR_OFFSET_Y = 19 + +/** + * Projects geographic coordinates to screen pixel position, scaled for the + * current map size and offset so the marker tip aligns with the point. + * + * @param {Array} coords - [lng, lat] geographic coordinates + * @param {Object} mapProvider - Map provider instance with `mapToScreen` method + * @param {string} mapSize - Current map size key (e.g. 'small', 'medium', 'large') + * @param {boolean} isMapReady - Whether the map has finished initialising + * @returns {{ x: number, y: number }} Screen position in pixels + */ export const projectCoords = (coords, mapProvider, mapSize, isMapReady) => { if (!mapProvider || !isMapReady) { return { x: 0, y: 0 } } const { x, y } = mapProvider.mapToScreen(coords) - return { x: x * scaleFactor[mapSize], y: y * scaleFactor[mapSize] - 19 } + return { + x: x * scaleFactor[mapSize], + y: y * scaleFactor[mapSize] - MARKER_ANCHOR_OFFSET_Y + } +} + +/** + * Reprojects and repositions all marker DOM elements to their current screen + * coordinates. Called on map render and resize events. + * + * @param {Array} items - Marker state items from the store + * @param {Map} markerRefs - Map of marker id to DOM element + * @param {Object} mapProvider - Map provider instance + * @param {string} mapSize - Current map size key + * @param {boolean} isMapReady - Whether the map has finished initialising + */ +const updateMarkerPositions = (items, markerRefs, mapProvider, mapSize, isMapReady) => { + items.forEach(marker => { + const ref = markerRefs.get(marker.id) + if (!ref || !marker.coords) { + return + } + + const { x, y } = projectCoords(marker.coords, mapProvider, mapSize, isMapReady) + ref.style.transform = `translate(${x}px, ${y}px)` + ref.style.display = 'block' + }) +} + +/** + * Registers event bus listeners for adding and removing markers via + * APP_ADD_MARKER and APP_REMOVE_MARKER events. + * + * @param {Object} eventBus - Application event bus + * @param {Object} markers - Markers API object with `add` and `remove` methods + */ +const useMarkerEventListeners = (eventBus, markers) => { + useEffect(() => { + const handleAddMarker = (payload = {}) => { + if (!payload?.id || !payload?.coords) { + return + } + const { id, coords, options } = payload + markers.add(id, coords, options) + } + eventBus.on(events.APP_ADD_MARKER, handleAddMarker) + + const handleRemoveMarker = (id) => { + if (!id) { + return + } + markers.remove(id) + } + eventBus.on(events.APP_REMOVE_MARKER, handleRemoveMarker) + + return () => { + eventBus.off(events.APP_ADD_MARKER, handleAddMarker) + eventBus.off(events.APP_REMOVE_MARKER, handleRemoveMarker) + } + }, []) } +/** + * Hook that provides the markers API and ref callback for positioning marker + * elements on the map. Attaches `add`, `remove`, and `getMarker` methods to the + * markers store object and keeps marker positions in sync with map render and + * resize events. + * + * @returns {{ markers: Object, markerRef: Function }} + */ export const useMarkers = () => { const { mapProvider } = useConfig() const { eventBus } = useService() const { markers, dispatch, mapSize, isMapReady } = useMap() const markerRefs = useRef(new Map()) - // --- API: Attach methods to markers object --- + // Attach add, remove, and getMarker methods to the markers store object useEffect(() => { if (!mapProvider) { return @@ -42,11 +121,11 @@ export const useMarkers = () => { } }, [mapProvider, markers, dispatch, mapSize]) - // Update marker position on events.MAP_RENDER + // Ref callback: stores marker DOM refs and subscribes to MAP_RENDER for repositioning const markerRef = useCallback((id) => (el) => { if (!el) { markerRefs.current.delete(id) - return + return undefined } markerRefs.current.set(id, el) @@ -54,17 +133,7 @@ export const useMarkers = () => { if (!isMapReady || !mapProvider) { return } - - markers.items.forEach(marker => { - const ref = markerRefs.current.get(marker.id) - if (!ref || !marker.coords) { - return - } - - const { x, y } = projectCoords(marker.coords, mapProvider, mapSize, isMapReady) - ref.style.transform = `translate(${x}px, ${y}px)` - ref.style.display = 'block' - }) + updateMarkerPositions(markers.items, markerRefs.current, mapProvider, mapSize, isMapReady) } eventBus.on(events.MAP_RENDER, updateMarkers) @@ -73,47 +142,16 @@ export const useMarkers = () => { } }, [markers, mapProvider, isMapReady, mapSize]) - // Update all markers on map resize + // Reproject all markers when the map size changes useEffect(() => { if (!isMapReady || !mapProvider) { return } - markers.items.forEach(marker => { - const ref = markerRefs.current.get(marker.id) - if (!ref || !marker.coords) { - return - } - - const { x, y } = projectCoords(marker.coords, mapProvider, mapSize, isMapReady) - ref.style.transform = `translate(${x}px, ${y}px)` - }) + updateMarkerPositions(markers.items, markerRefs.current, mapProvider, mapSize, isMapReady) }, [mapSize, markers.items, mapProvider, isMapReady]) - // Respond to external API calls via eventBus - useEffect(() => { - const handleAddMarker = (payload = {}) => { - if (!payload || !payload.id || !payload.coords) { - return - } - const { id, coords, options } = payload - markers.add(id, coords, options) - } - eventBus.on(events.APP_ADD_MARKER, handleAddMarker) - - const handleRemoveMarker = (id) => { - if (!id) { - return - } - markers.remove(id) - } - eventBus.on(events.APP_REMOVE_MARKER, handleRemoveMarker) - - return () => { - eventBus.off(events.APP_ADD_MARKER, handleAddMarker) - eventBus.off(events.APP_REMOVE_MARKER, handleRemoveMarker) - } - }, []) + useMarkerEventListeners(eventBus, markers) return { markers, markerRef } } diff --git a/src/App/hooks/useModalPanelBehaviour.js b/src/App/hooks/useModalPanelBehaviour.js index a8f74427..dc27240f 100755 --- a/src/App/hooks/useModalPanelBehaviour.js +++ b/src/App/hooks/useModalPanelBehaviour.js @@ -3,18 +3,10 @@ import { useResizeObserver } from './useResizeObserver.js' import { constrainKeyboardFocus } from '../../utils/constrainKeyboardFocus.js' import { toggleInertElements } from '../../utils/toggleInertElements.js' -export function useModalPanelBehaviour ({ - mainRef, - panelRef, - isModal, - rootEl, - buttonContainerEl, - handleClose -}) { - // === Escape and Tab key handling === // +const useModalKeyHandler = (panelRef, isModal, handleClose) => { useEffect(() => { if (!isModal) { - return + return undefined } const handleKeyDown = (e) => { @@ -36,6 +28,47 @@ export function useModalPanelBehaviour ({ current?.removeEventListener('keydown', handleKeyDown) } }, [isModal, panelRef, handleClose]) +} + +const useFocusRedirect = (isModal, panelRef, rootEl) => { + useEffect(() => { + if (!isModal) { + return undefined + } + + const handleFocusIn = (e) => { + const focusedEl = e.target + const panelEl = panelRef.current + + if (!focusedEl || !panelEl || !rootEl) { + return undefined + } + + const isInsideApp = rootEl.contains(focusedEl) + const isInsidePanel = panelEl.contains(focusedEl) + + if (isInsideApp && !isInsidePanel) { + panelEl.focus() + } + } + + document.addEventListener('focusin', handleFocusIn) + + return () => { + document.removeEventListener('focusin', handleFocusIn) + } + }, [isModal, panelRef, rootEl]) +} + +export function useModalPanelBehaviour ({ + mainRef, + panelRef, + isModal, + rootEl, + buttonContainerEl, + handleClose +}) { + useModalKeyHandler(panelRef, isModal, handleClose) // === Set absolute offset positions and recalculate on mainRef resize === // const root = document.documentElement @@ -55,7 +88,7 @@ export function useModalPanelBehaviour ({ // === Click on modal backdrop to close === // useEffect(() => { if (!isModal) { - return + return undefined } const handleClick = (e) => { @@ -74,7 +107,7 @@ export function useModalPanelBehaviour ({ // === Inert everything outside the panel but within the app === // useEffect(() => { if (!isModal || !panelRef.current || !rootEl) { - return + return undefined } toggleInertElements({ @@ -92,32 +125,5 @@ export function useModalPanelBehaviour ({ } }, [isModal, panelRef, rootEl]) - // === Redirect focus into the panel if it enters the app === // - useEffect(() => { - if (!isModal) { - return - } - - const handleFocusIn = (e) => { - const focusedEl = e.target - const panelEl = panelRef.current - - if (!focusedEl || !panelEl || !rootEl) { - return - } - - const isInsideApp = rootEl.contains(focusedEl) - const isInsidePanel = panelEl.contains(focusedEl) - - if (isInsideApp && !isInsidePanel) { - panelEl.focus() - } - } - - document.addEventListener('focusin', handleFocusIn) - - return () => { - document.removeEventListener('focusin', handleFocusIn) - } - }, [isModal, panelRef, rootEl]) + useFocusRedirect(isModal, panelRef, rootEl) } diff --git a/src/App/initialiseApp.js b/src/App/initialiseApp.js index a905b63e..f0e1ae96 100755 --- a/src/App/initialiseApp.js +++ b/src/App/initialiseApp.js @@ -13,6 +13,47 @@ const rootMap = new WeakMap() const mapProviderMap = new WeakMap() const registryMap = new WeakMap() +const getOrCreateRegistries = (rootElement) => { + let registries = registryMap.get(rootElement) + if (!registries) { + const buttonRegistry = createButtonRegistry() + const panelRegistry = createPanelRegistry() + const controlRegistry = createControlRegistry() + const pluginRegistry = createPluginRegistry({ + registerButton: buttonRegistry.registerButton, + registerPanel: panelRegistry.registerPanel, + registerControl: controlRegistry.registerControl + }) + + registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry } + registryMap.set(rootElement, registries) + } + return registries +} + +const loadPlugins = async (plugins, registerPlugin) => { + for (const plugin of plugins) { + if (typeof plugin.load === 'function') { + const module = await plugin.load() + const { id: pluginId, load, manifest: overrideManifest, ...config } = plugin + const { InitComponent, api, reducer, ...baseManifest } = module + + // Merge runtime overrides with module manifest + const manifest = mergeManifests(baseManifest, overrideManifest) + + registerPlugin({ + id: pluginId, + InitComponent, + api, + reducer, + config, + manifest, + _originalPlugin: plugin + }) + } + } +} + export async function initialiseApp (rootElement, { MapProvider: MapProviderClass, mapProviderConfig, @@ -35,22 +76,7 @@ export async function initialiseApp (rootElement, { } // Reuse or create registries (persist across app open/close cycles) - let registries = registryMap.get(rootElement) - if (!registries) { - const buttonRegistry = createButtonRegistry() - const panelRegistry = createPanelRegistry() - const controlRegistry = createControlRegistry() - const pluginRegistry = createPluginRegistry({ - registerButton: buttonRegistry.registerButton, - registerPanel: panelRegistry.registerPanel, - registerControl: controlRegistry.registerControl - }) - - registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry } - registryMap.set(rootElement, registries) - } - - const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry } = registries + const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry } = getOrCreateRegistries(rootElement) const { registerPlugin } = pluginRegistry // Clear previous plugins (but keep runtime additions) @@ -87,26 +113,7 @@ export async function initialiseApp (rootElement, { } // Load plugins - for (const plugin of plugins) { - if (typeof plugin.load === 'function') { - const module = await plugin.load() - const { id: pluginId, load, manifest: overrideManifest, ...config } = plugin - const { InitComponent, api, reducer, ...baseManifest } = module - - // Merge runtime overrides with module manifest - const manifest = mergeManifests(baseManifest, overrideManifest) - - registerPlugin({ - id: pluginId, - InitComponent, - api, - reducer, - config, - manifest, - _originalPlugin: plugin - }) - } - } + await loadPlugins(plugins, registerPlugin) root.render( { if (typeof config.onClick === 'function') { - return config.onClick(e, evaluateProp(ctx => ctx, config.pluginId)) + config.onClick(e, evaluateProp(ctx => ctx, config.pluginId)) + return } if (config.panelId) { @@ -115,10 +116,10 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp }) { return matching.map((btn, idx) => { const [buttonId, config] = btn const key = config.group - const indices = key != null ? groupMap.get(key) : null + const indices = key == null ? null : groupMap.get(key) const groupStart = indices ? idx === indices[0] : false const groupEnd = indices ? idx === indices[indices.length - 1] : false - const groupMiddle = indices && indices.length >= 3 && !groupStart && !groupEnd + const groupMiddle = indices && indices.length >= 3 && !groupStart && !groupEnd // NOSONAR: 3 = minimum for a start/middle/end group const order = config[breakpoint]?.order ?? 0 return { diff --git a/src/utils/toggleInertElements.js b/src/utils/toggleInertElements.js index de3da343..99cd1988 100755 --- a/src/utils/toggleInertElements.js +++ b/src/utils/toggleInertElements.js @@ -1,31 +1,56 @@ +/** + * Checks whether a sibling element should be marked as inert. + * @param {Element} sibling - The sibling element to evaluate + * @param {Element} el - The current element being walked up from + * @param {Element|null} containerEl - The container to preserve (not make inert) + * @param {Element} boundaryEl - The outermost boundary for the traversal + * @returns {boolean} + */ +const shouldMakeInert = (sibling, el, containerEl, boundaryEl) => + sibling !== el && + !containerEl?.contains(sibling) && + sibling.matches(':not([aria-hidden]):not([data-fm-inert])') && + boundaryEl.contains(sibling) + +/** + * Toggles inert state on elements outside a container for fullscreen/modal behaviour. + * + * When entering fullscreen, walks up from `containerEl` to `boundaryEl`, marking all + * sibling branches as `aria-hidden` and flagging them with `data-fm-inert` so they + * can be restored later. When exiting fullscreen, restores all previously inerted elements. + * + * @param {Object} options + * @param {Element|null} options.containerEl - The element to keep interactive + * @param {boolean} options.isFullscreen - Whether fullscreen mode is active + * @param {Element} [options.boundaryEl=document.body] - The outermost ancestor to traverse up to + */ export function toggleInertElements ({ containerEl, isFullscreen, boundaryEl = document.body }) { + // Restore any previously inerted elements let inertElements = Array.from(boundaryEl.querySelectorAll('[data-fm-inert]')) if (containerEl) { - inertElements = inertElements.filter(el => !containerEl.contains(el)) + inertElements = inertElements.filter(inertEl => !containerEl.contains(inertEl)) } - inertElements.forEach(el => { - el.removeAttribute('aria-hidden') - el.removeAttribute('data-fm-inert') + inertElements.forEach(inertEl => { + inertEl.removeAttribute('aria-hidden') + delete inertEl.dataset.fmInert }) - if (!isFullscreen) return + if (!isFullscreen) { + return + } + // Entering fullscreen: blur active element and inert all sibling branches document.activeElement?.blur() let el = containerEl while (el?.parentNode && el !== boundaryEl && el !== document.body) { const parent = el.parentNode for (const sibling of parent.children) { - if ( - sibling !== el && - (!containerEl || !containerEl.contains(sibling)) && - sibling.matches(':not([aria-hidden]):not([data-fm-inert])') && - boundaryEl.contains(sibling) - ) { + if (shouldMakeInert(sibling, el, containerEl, boundaryEl)) { sibling.setAttribute('aria-hidden', 'true') - sibling.setAttribute('data-fm-inert', '') + sibling.dataset.fmInert = '' } } el = parent