@defra/interactive-map 0.0.43-fmp-experimental → 0.0.44-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/assets/templates/add-polygons.njk +5 -1
- package/assets/templates/add-symbols.njk +5 -1
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/context.md +24 -0
- package/docs/api/keyboard-shortcut-definition.md +102 -0
- package/docs/api.md +18 -8
- package/docs/building-a-plugin.md +1 -0
- package/docs/plugins/draw.md +2 -2
- package/docs/plugins/plugin-manifest.md +9 -0
- package/package.json +4 -2
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
- package/plugins/beta/draw-es/src/events.js +17 -8
- package/plugins/beta/draw-es/src/reducer.js +8 -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-ol/dist/css/index.css +1 -13
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
- package/plugins/beta/frame/dist/css/index.css +1 -11
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/src/Frame.jsx +28 -2
- package/plugins/beta/map-styles/dist/css/index.css +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/beta/scale-bar/dist/css/index.css +1 -8
- package/plugins/datasets/dist/css/5648.index.css +1 -5
- package/plugins/datasets/dist/css/index.css +1 -1
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
- package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
- package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
- package/plugins/datasets/src/registry/dataset.js +8 -12
- package/plugins/datasets/src/registry/dataset.test.js +17 -0
- package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
- package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/esm/index.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/index.js +1 -1
- package/plugins/draw/src/DrawInit.jsx +41 -26
- package/plugins/draw/src/DrawInit.test.jsx +55 -12
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
- package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
- package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
- package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
- package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
- package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
- package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
- package/plugins/draw/src/events.js +4 -4
- package/plugins/draw/src/events.test.js +1 -1
- package/plugins/draw/src/hooks/useSpatialList.js +180 -0
- package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
- package/plugins/draw/src/manifest.js +4 -1
- package/plugins/draw/src/manifest.test.js +19 -7
- package/plugins/draw/src/utils/spatial.js +1 -31
- package/plugins/draw/src/utils/spatial.test.js +1 -24
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +2 -2
- package/plugins/interact/src/InteractInit.test.js +3 -3
- package/plugins/interact/src/events.js +11 -2
- package/plugins/interact/src/events.test.js +32 -0
- package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
- package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
- package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
- package/plugins/interact/src/hooks/useSpatialList.js +251 -0
- package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
- package/plugins/interact/src/manifest.js +12 -1
- package/plugins/interact/src/manifest.test.js +20 -0
- package/plugins/interact/src/utils/spatial.js +24 -1
- package/plugins/interact/src/utils/spatial.test.js +26 -1
- package/plugins/map-key/dist/css/index.css +1 -106
- package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/index.js +1 -1
- package/plugins/map-key/src/components/Key/Key.jsx +3 -4
- package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
- package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
- package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
- package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
- package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
- package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
- package/plugins/map-key/src/manifest.js +1 -1
- package/plugins/map-key/src/utils/groupStyles.js +7 -0
- package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
- package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
- package/plugins/menu/dist/css/index.css +1 -80
- package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
- package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
- package/plugins/menu/dist/umd/index.js +1 -1
- package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
- package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
- package/plugins/menu/src/initialise/createMenu.js +3 -1
- package/plugins/menu/src/initialise/createMenu.test.js +20 -5
- package/plugins/menu/src/initialise/updateUrl.js +44 -0
- package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
- package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
- package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
- package/plugins/menu/src/reducers/pluginState.js +1 -6
- package/plugins/menu/src/reducers/pluginState.test.js +0 -25
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/manifest.js +5 -9
- package/plugins/search/src/manifest.test.js +5 -0
- package/providers/beta/esri/dist/css/index.css +1 -34
- 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/src/openlayersProvider.js +3 -3
- package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
- package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/defaults.test.js +26 -0
- package/providers/maplibre/src/utils/labels.js +2 -2
- package/providers/maplibre/src/utils/labels.test.js +5 -5
- package/providers/maplibre/src/utils/spatial.js +0 -57
- package/providers/maplibre/src/utils/spatial.test.js +0 -27
- package/rollup.esm.mjs +2 -1
- package/scripts/publish-package.sh +36 -0
- package/src/App/App.jsx +1 -1
- package/src/App/App.test.jsx +6 -1
- package/src/App/components/CrossHair/CrossHair.jsx +25 -11
- package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
- package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
- package/src/App/components/Hints/Hints.jsx +3 -3
- package/src/App/components/Hints/Hints.test.jsx +3 -3
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
- package/src/App/components/MapButton/MapButton.jsx +19 -7
- package/src/App/components/MapButton/MapButton.module.scss +1 -2
- package/src/App/components/MapButton/MapButton.test.jsx +42 -4
- package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
- package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
- package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
- package/src/App/components/Markers/Markers.jsx +2 -2
- package/src/App/components/Markers/Markers.test.jsx +3 -3
- package/src/App/components/Markers/SymbolMarker.jsx +2 -3
- package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
- package/src/App/components/Panel/Panel.jsx +19 -20
- package/src/App/components/Panel/Panel.module.scss +5 -0
- package/src/App/components/Panel/Panel.test.jsx +69 -4
- package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
- package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
- package/src/App/components/Viewport/MapStatus.jsx +1 -1
- package/src/App/components/Viewport/SpatialList.jsx +47 -0
- package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
- package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
- package/src/App/components/Viewport/Viewport.jsx +21 -11
- package/src/App/components/Viewport/Viewport.test.jsx +18 -2
- package/src/App/controls/keyboardMappings.js +7 -2
- package/src/App/controls/keyboardMappings.test.js +21 -0
- package/src/App/controls/keyboardShortcuts.js +16 -2
- package/src/App/controls/keyboardShortcuts.test.js +21 -0
- package/src/App/hooks/useCrossHairAPI.js +29 -1
- package/src/App/hooks/useCrossHairAPI.test.js +17 -1
- package/src/App/hooks/useHintsAPI.js +5 -5
- package/src/App/hooks/useInterfaceAPI.js +2 -1
- package/src/App/hooks/useInterfaceAPI.test.js +9 -0
- package/src/App/hooks/useKeyboardShortcuts.js +66 -35
- package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
- package/src/App/hooks/useModalPanelBehaviour.js +48 -34
- package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
- package/src/App/hooks/useSpatialListFocus.js +296 -0
- package/src/App/hooks/useSpatialListFocus.test.js +957 -0
- package/src/App/hooks/useSpatialListItems.js +51 -0
- package/src/App/hooks/useSpatialListItems.test.js +217 -0
- package/src/App/initialiseApp.js +6 -3
- package/src/App/initialiseApp.test.js +11 -0
- package/src/App/layout/Layout.jsx +4 -2
- package/src/App/layout/Layout.test.jsx +21 -0
- package/src/App/layout/layout.module.scss +7 -1
- package/src/App/registry/keyboardShortcutRegistry.js +2 -2
- package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
- package/src/App/registry/pluginRegistry.js +13 -0
- package/src/App/registry/pluginRegistry.test.js +15 -0
- package/src/App/registry/spatialListRegistry.js +104 -0
- package/src/App/registry/spatialListRegistry.test.js +218 -0
- package/src/App/renderer/HtmlElementHost.jsx +12 -32
- package/src/App/renderer/mapButtons.js +4 -0
- package/src/App/renderer/mapButtons.test.js +20 -0
- package/src/App/renderer/mapPanels.js +33 -53
- package/src/App/renderer/mapPanels.test.js +29 -4
- package/src/App/renderer/slotHelpers.js +33 -0
- package/src/App/renderer/slotHelpers.test.js +79 -2
- package/src/App/store/AppProvider.jsx +3 -2
- package/src/App/store/ServiceProvider.jsx +5 -3
- package/src/App/store/ServiceProvider.test.jsx +20 -1
- package/src/InteractiveMap/InteractiveMap.js +13 -2
- package/src/InteractiveMap/InteractiveMap.test.js +33 -0
- package/src/config/appConfig.js +16 -16
- package/src/config/appConfig.test.js +19 -19
- package/src/config/defaults.js +2 -2
- package/src/config/events.js +8 -8
- package/src/scss/main.scss +2 -2
- package/src/test-utils.js +8 -0
- package/src/types.js +51 -9
- package/src/utils/findNearestItemInDirection.js +29 -0
- package/src/utils/findNearestItemInDirection.test.js +67 -0
- package/src/utils/getPanelElementId.js +4 -0
- package/src/utils/getPanelElementId.test.js +11 -0
- package/src/utils/getPanelRole.js +22 -0
- package/src/utils/getPanelRole.test.js +56 -0
- package/src/utils/globalAltShortcuts.js +12 -0
- package/src/utils/globalAltShortcuts.test.js +27 -0
- package/src/utils/spatialNavigate.js +49 -0
- package/src/utils/spatialNavigate.test.js +42 -0
- package/webpack.umd.mjs +6 -1
- package/plugins/interact/src/hooks/useMapItemList.js +0 -187
- package/src/App/components/Viewport/Features.jsx +0 -35
- package/src/App/components/Viewport/Features.test.jsx +0 -124
- package/src/App/hooks/useFeatureFocus.js +0 -199
- package/src/App/hooks/useFeatureFocus.test.js +0 -635
- package/src/App/hooks/useFeatureItems.js +0 -39
- package/src/App/hooks/useFeatureItems.test.js +0 -155
|
@@ -5,6 +5,71 @@ import { useConfig } from '../store/configContext.js'
|
|
|
5
5
|
import { useApp } from '../store/appContext.js'
|
|
6
6
|
import { useService } from '../store/serviceContext.js'
|
|
7
7
|
|
|
8
|
+
const normalizeKey = (e) => {
|
|
9
|
+
let key
|
|
10
|
+
|
|
11
|
+
// Use e.code for letters to avoid 'dead' keys with Alt/AltGr
|
|
12
|
+
if (/^Key[A-Z]$/.test(e.code)) {
|
|
13
|
+
key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
|
|
14
|
+
} else {
|
|
15
|
+
key = e.key // works for arrows, numpad, punctuation
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Normalize numpad add/subtract to symbols
|
|
19
|
+
if (key === 'Add' || key === 'NumpadAdd') {
|
|
20
|
+
key = '+'
|
|
21
|
+
} else if (key === 'Subtract' || key === 'NumpadSubtract') {
|
|
22
|
+
key = '-'
|
|
23
|
+
} else {
|
|
24
|
+
// No action
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check altKey first: AltGr (used on many non-US keyboards) reports both altKey
|
|
28
|
+
// and ctrlKey as true, and should still resolve to the Alt+ binding, not Ctrl+.
|
|
29
|
+
if (e.altKey) {
|
|
30
|
+
return `Alt+${key}`
|
|
31
|
+
}
|
|
32
|
+
if (e.ctrlKey) {
|
|
33
|
+
return `Ctrl+${key}`
|
|
34
|
+
}
|
|
35
|
+
return key
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const resolveAction = (actions, type, key) => {
|
|
39
|
+
const actionName = keyboardMappings[type][key]
|
|
40
|
+
return actionName && actions[actionName] ? actionName : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// keydown (pan/zoom) stays on the viewport so arrows only fire when the map has focus.
|
|
44
|
+
// keyup (Alt+K and other global shortcuts) attaches to the app container so it fires
|
|
45
|
+
// from anywhere within the app, including the features listbox.
|
|
46
|
+
const createKeyEventHandlers = (actions) => {
|
|
47
|
+
const handleKeyDown = (e) => {
|
|
48
|
+
const key = normalizeKey(e)
|
|
49
|
+
const actionName = resolveAction(actions, 'keydown', key)
|
|
50
|
+
if (actionName) {
|
|
51
|
+
actions[actionName](e)
|
|
52
|
+
e.preventDefault()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
// A keyup-bound shortcut (e.g. Alt+Arrow label navigation) still acts on release,
|
|
56
|
+
// but its browser default (e.g. arrow-key scroll) must be suppressed now or it's too late.
|
|
57
|
+
if (resolveAction(actions, 'keyup', key)) {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const handleKeyUp = (e) => {
|
|
63
|
+
const actionName = resolveAction(actions, 'keyup', normalizeKey(e))
|
|
64
|
+
if (actionName) {
|
|
65
|
+
actions[actionName](e)
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { handleKeyDown, handleKeyUp }
|
|
71
|
+
}
|
|
72
|
+
|
|
8
73
|
export function useKeyboardShortcuts (containerRef) {
|
|
9
74
|
const { mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta, readMapText } = useConfig()
|
|
10
75
|
const { dispatch, layoutRefs } = useApp()
|
|
@@ -27,42 +92,8 @@ export function useKeyboardShortcuts (containerRef) {
|
|
|
27
92
|
readMapText
|
|
28
93
|
})
|
|
29
94
|
|
|
30
|
-
const
|
|
31
|
-
let key
|
|
32
|
-
|
|
33
|
-
// Use e.code for letters to avoid 'dead' keys with Alt/AltGr
|
|
34
|
-
if (/^Key[A-Z]$/.test(e.code)) {
|
|
35
|
-
key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
|
|
36
|
-
} else {
|
|
37
|
-
key = e.key // works for arrows, numpad, punctuation
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Normalize numpad add/subtract to symbols
|
|
41
|
-
if (key === 'Add' || key === 'NumpadAdd') {
|
|
42
|
-
key = '+'
|
|
43
|
-
} else if (key === 'Subtract' || key === 'NumpadSubtract') {
|
|
44
|
-
key = '-'
|
|
45
|
-
} else {
|
|
46
|
-
// No action
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return e.altKey ? `Alt+${key}` : key
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const handle = (type) => (e) => {
|
|
53
|
-
const actionName = keyboardMappings[type][normalizeKey(e)]
|
|
54
|
-
if (actionName && actions[actionName]) {
|
|
55
|
-
actions[actionName](e)
|
|
56
|
-
e.preventDefault()
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const handleKeyDown = handle('keydown')
|
|
61
|
-
const handleKeyUp = handle('keyup')
|
|
95
|
+
const { handleKeyDown, handleKeyUp } = createKeyEventHandlers(actions)
|
|
62
96
|
|
|
63
|
-
// keydown (pan/zoom) stays on the viewport so arrows only fire when the map has focus.
|
|
64
|
-
// keyup (Alt+K and other global shortcuts) attaches to the app container so it fires
|
|
65
|
-
// from anywhere within the app, including the features listbox.
|
|
66
97
|
el.addEventListener('keydown', handleKeyDown)
|
|
67
98
|
appEl.addEventListener('keyup', handleKeyUp)
|
|
68
99
|
|
|
@@ -138,6 +138,58 @@ describe('useKeyboardShortcuts', () => {
|
|
|
138
138
|
expect(actions.stopPan).toHaveBeenCalled()
|
|
139
139
|
})
|
|
140
140
|
|
|
141
|
+
test('does nothing on keyup when the key has no mapped action', () => {
|
|
142
|
+
const { appContainerRef, actions } = setup({ keyup: { ArrowUp: 'stopPan' }, actions: { stopPan: jest.fn() } })
|
|
143
|
+
renderHook(() => useKeyboardShortcuts({ current: document.createElement('div') }))
|
|
144
|
+
|
|
145
|
+
const evt = new KeyboardEvent('keyup', { key: 'Z' })
|
|
146
|
+
const spy = jest.spyOn(evt, 'preventDefault')
|
|
147
|
+
appContainerRef.current.dispatchEvent(evt)
|
|
148
|
+
|
|
149
|
+
expect(spy).not.toHaveBeenCalled()
|
|
150
|
+
expect(actions.stopPan).not.toHaveBeenCalled()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('suppresses the browser default on keydown for a keyup-bound shortcut, without firing the action early', () => {
|
|
154
|
+
const { containerRef, appContainerRef, actions } = setup({
|
|
155
|
+
keyup: { 'Alt+ArrowUp': 'highlightNextLabel' },
|
|
156
|
+
actions: { highlightNextLabel: jest.fn() }
|
|
157
|
+
})
|
|
158
|
+
renderHook(() => useKeyboardShortcuts(containerRef))
|
|
159
|
+
|
|
160
|
+
const keydownEvt = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true })
|
|
161
|
+
const keydownSpy = jest.spyOn(keydownEvt, 'preventDefault')
|
|
162
|
+
containerRef.current.dispatchEvent(keydownEvt)
|
|
163
|
+
expect(keydownSpy).toHaveBeenCalled()
|
|
164
|
+
expect(actions.highlightNextLabel).not.toHaveBeenCalled()
|
|
165
|
+
|
|
166
|
+
appContainerRef.current.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp', altKey: true }))
|
|
167
|
+
expect(actions.highlightNextLabel).toHaveBeenCalled()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('normalizes Ctrl+key combinations', () => {
|
|
171
|
+
const { containerRef, actions } = setup({
|
|
172
|
+
keydown: { 'Ctrl+I': 'getInfo' },
|
|
173
|
+
actions: { getInfo: jest.fn() }
|
|
174
|
+
})
|
|
175
|
+
renderHook(() => useKeyboardShortcuts(containerRef))
|
|
176
|
+
|
|
177
|
+
containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyI', key: 'i', ctrlKey: true }))
|
|
178
|
+
expect(actions.getInfo).toHaveBeenCalled()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('treats AltGr (altKey + ctrlKey both true) as Alt+, not Ctrl+', () => {
|
|
182
|
+
const { containerRef, actions } = setup({
|
|
183
|
+
keydown: { 'Alt+I': 'getInfo', 'Ctrl+I': 'zoomIn' },
|
|
184
|
+
actions: { getInfo: jest.fn(), zoomIn: jest.fn() }
|
|
185
|
+
})
|
|
186
|
+
renderHook(() => useKeyboardShortcuts(containerRef))
|
|
187
|
+
|
|
188
|
+
containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyI', key: 'i', altKey: true, ctrlKey: true }))
|
|
189
|
+
expect(actions.getInfo).toHaveBeenCalled()
|
|
190
|
+
expect(actions.zoomIn).not.toHaveBeenCalled()
|
|
191
|
+
})
|
|
192
|
+
|
|
141
193
|
test('prevents default when action exists, does nothing otherwise', () => {
|
|
142
194
|
const { containerRef } = setup({ keydown: { I: 'zoomIn', X: 'nonExistent' } })
|
|
143
195
|
renderHook(() => useKeyboardShortcuts(containerRef))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect } from 'react'
|
|
1
|
+
import { useEffect, useLayoutEffect } from 'react'
|
|
2
2
|
import { useResizeObserver } from './useResizeObserver.js'
|
|
3
3
|
import { constrainKeyboardFocus } from '../../utils/constrainKeyboardFocus.js'
|
|
4
4
|
import { toggleInertElements } from '../../utils/toggleInertElements.js'
|
|
@@ -62,6 +62,44 @@ const setSlotCSSVar = (slot, layoutRefs, primaryMargin) => {
|
|
|
62
62
|
root.style.setProperty(MODAL_MAX_HEIGHT, `${mainRect.height - relTop - primaryMargin}px`)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Computes and applies --modal-inset/--modal-max-height for the panel's current slot. Shared by
|
|
66
|
+
// the resize observer (keeps position correct across later layout changes while open) and the
|
|
67
|
+
// open-triggered layout effect below (panels stay permanently mounted — see mapPanels.js — so
|
|
68
|
+
// opening one doesn't itself resize mainRef and wouldn't otherwise trigger a recalc).
|
|
69
|
+
const recalcModalPosition = ({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs }) => {
|
|
70
|
+
if (!isModal || !mainRef.current) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const root = document.documentElement
|
|
75
|
+
const styles = getComputedStyle(root)
|
|
76
|
+
const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
|
|
77
|
+
const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
|
|
78
|
+
const slot = panelRef.current.dataset.slot
|
|
79
|
+
|
|
80
|
+
// Button-adjacent panels: position next to the controlling button.
|
|
81
|
+
// Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
|
|
82
|
+
// when there is no triggeringElement (e.g. panel opened programmatically).
|
|
83
|
+
// Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
|
|
84
|
+
if (slot?.endsWith('-button')) {
|
|
85
|
+
const panelElId = panelRef.current?.id
|
|
86
|
+
const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
|
|
87
|
+
const effectiveContainer = currentButtonEl?.parentElement ??
|
|
88
|
+
(buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
|
|
89
|
+
document.querySelector(`[data-button-slot="${slot}"]`)
|
|
90
|
+
|
|
91
|
+
if (!effectiveContainer) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Slot-based panels: derive position from the slot container element
|
|
100
|
+
setSlotCSSVar(slot, layoutRefs, primaryMargin)
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
const useModalKeyHandler = (panelRef, isModal, handleClose) => {
|
|
66
104
|
useEffect(() => {
|
|
67
105
|
if (!isModal) {
|
|
@@ -131,40 +169,16 @@ export function useModalPanelBehaviour ({
|
|
|
131
169
|
|
|
132
170
|
useModalKeyHandler(panelRef, isModal, handleClose)
|
|
133
171
|
|
|
134
|
-
// ===
|
|
135
|
-
useResizeObserver([mainRef], () => {
|
|
136
|
-
if (!isModal || !mainRef.current) {
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const root = document.documentElement
|
|
141
|
-
const styles = getComputedStyle(root)
|
|
142
|
-
const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
|
|
143
|
-
const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
|
|
144
|
-
const slot = panelRef.current.dataset.slot
|
|
145
|
-
|
|
146
|
-
// Button-adjacent panels: position next to the controlling button.
|
|
147
|
-
// Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
|
|
148
|
-
// when there is no triggeringElement (e.g. panel opened programmatically).
|
|
149
|
-
// Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
|
|
150
|
-
if (slot?.endsWith('-button')) {
|
|
151
|
-
const panelElId = panelRef.current?.id
|
|
152
|
-
const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
|
|
153
|
-
const effectiveContainer = currentButtonEl?.parentElement ??
|
|
154
|
-
(buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
|
|
155
|
-
document.querySelector(`[data-button-slot="${slot}"]`)
|
|
156
|
-
|
|
157
|
-
if (!effectiveContainer) {
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
|
|
162
|
-
return
|
|
163
|
-
}
|
|
172
|
+
// === Recalculate on mainRef resize (keeps position correct while already open) === //
|
|
173
|
+
useResizeObserver([mainRef], () => recalcModalPosition({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs }))
|
|
164
174
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
175
|
+
// === Recalculate the moment the panel opens === //
|
|
176
|
+
// The resize observer above won't fire on its own here: its size cache persists across
|
|
177
|
+
// opens/closes (the panel no longer mounts fresh each time), and opening a panel doesn't
|
|
178
|
+
// itself resize mainRef. This is what actually positions the panel at open time.
|
|
179
|
+
useLayoutEffect(() => {
|
|
180
|
+
recalcModalPosition({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs })
|
|
181
|
+
}, [isModal, mainRef, panelRef, buttonContainerEl, layoutRefs])
|
|
168
182
|
|
|
169
183
|
// === Click on modal backdrop to close === //
|
|
170
184
|
useEffect(() => {
|
|
@@ -218,6 +218,65 @@ describe('useModalPanelBehaviour', () => {
|
|
|
218
218
|
})
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
+
// Regression: panels are mounted permanently and only toggle `hidden` (see mapPanels.js), so
|
|
222
|
+
// opening one doesn't itself resize mainRef. This uses the *real* useResizeObserver (backed by
|
|
223
|
+
// a fake ResizeObserver, not the blanket `(_, cb) => cb()` mock used above) so its size-change
|
|
224
|
+
// dedupe is actually exercised — that dedupe is what silently swallowed the reposition on open.
|
|
225
|
+
describe('repositioning on open when mainRef never resizes', () => {
|
|
226
|
+
const buttonSlot = 'map-styles-button'
|
|
227
|
+
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
const { useResizeObserver: actualUseResizeObserver } = jest.requireActual('./useResizeObserver.js')
|
|
230
|
+
useResizeObserverModule.useResizeObserver.mockImplementation(actualUseResizeObserver)
|
|
231
|
+
|
|
232
|
+
// Real ResizeObserver always delivers once per observe() call — mirror that, and let the
|
|
233
|
+
// real hook's own prevSizes dedupe decide whether the wrapped callback actually runs.
|
|
234
|
+
global.ResizeObserver = jest.fn(function (cb) {
|
|
235
|
+
this.observe = (target) => {
|
|
236
|
+
const entries = [{ target, contentRect: { width: 100, height: 50 } }]
|
|
237
|
+
cb(entries)
|
|
238
|
+
}
|
|
239
|
+
this.disconnect = jest.fn()
|
|
240
|
+
})
|
|
241
|
+
jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 0 })
|
|
242
|
+
jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
243
|
+
jest.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ getPropertyValue: () => '8' })
|
|
244
|
+
|
|
245
|
+
Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
|
|
246
|
+
value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 }),
|
|
247
|
+
configurable: true
|
|
248
|
+
})
|
|
249
|
+
Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
|
|
250
|
+
value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 }),
|
|
251
|
+
configurable: true
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
refs.panel.current.dataset.slot = buttonSlot
|
|
255
|
+
const button = document.createElement('button')
|
|
256
|
+
button.setAttribute(ARIA_CONTROLS, PANEL_ID)
|
|
257
|
+
elements.buttonContainer.appendChild(button)
|
|
258
|
+
document.body.appendChild(elements.buttonContainer)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
afterEach(() => {
|
|
262
|
+
jest.restoreAllMocks()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('positions the panel the moment it opens, not just when mainRef resizes', () => {
|
|
266
|
+
// Mounted closed, as it would be permanently — mainRef's size gets recorded but the
|
|
267
|
+
// calc no-ops on the isModal guard.
|
|
268
|
+
const { rerender } = render(<TestComponent isModal={false} />)
|
|
269
|
+
expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('')
|
|
270
|
+
|
|
271
|
+
// Open it. mainRef hasn't resized, so the resize observer's own delivery is deduped
|
|
272
|
+
// away — only the open-triggered layout effect can position it here.
|
|
273
|
+
rerender(<TestComponent isModal />)
|
|
274
|
+
|
|
275
|
+
expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
|
|
276
|
+
expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
221
280
|
describe('focus management', () => {
|
|
222
281
|
it('redirects focus into panel when focus enters app but outside panel', () => {
|
|
223
282
|
refs.panel.current.focus = jest.fn()
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { EVENTS } from '../../config/events.js'
|
|
3
|
+
import { findNearestItemInDirection } from '../../utils/findNearestItemInDirection.js'
|
|
4
|
+
import { spatialNavigate } from '../../utils/spatialNavigate.js'
|
|
5
|
+
import { stopIfGlobalAltKey } from '../../utils/globalAltShortcuts.js'
|
|
6
|
+
|
|
7
|
+
const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
|
|
8
|
+
|
|
9
|
+
// Whichever item is nearest the map's own center point on screen — the entry-point fallback
|
|
10
|
+
// below prefers this over the structurally-first item.
|
|
11
|
+
const findNearestToCenter = (items, centerScreenPoint) => {
|
|
12
|
+
if (!centerScreenPoint) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
const positioned = items.filter(item => item.x != null && item.y != null)
|
|
16
|
+
if (!positioned.length) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
const pixels = positioned.map(item => [item.x, item.y])
|
|
20
|
+
const idx = spatialNavigate([centerScreenPoint.x, centerScreenPoint.y], pixels, undefined)
|
|
21
|
+
return positioned[idx].id
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const getNavigatedId = (id, key, items) => {
|
|
25
|
+
if (!items.length) {
|
|
26
|
+
return id
|
|
27
|
+
}
|
|
28
|
+
if (key === 'Home') {
|
|
29
|
+
return items[0].id
|
|
30
|
+
}
|
|
31
|
+
if (key === 'End') {
|
|
32
|
+
return items[items.length - 1].id
|
|
33
|
+
}
|
|
34
|
+
const idx = items.findIndex(item => item.id === id)
|
|
35
|
+
if (key === 'ArrowDown') {
|
|
36
|
+
return idx === -1 ? items[0].id : items[Math.min(idx + 1, items.length - 1)].id
|
|
37
|
+
}
|
|
38
|
+
return idx === -1 ? items[items.length - 1].id : items[Math.max(idx - 1, 0)].id
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ARIA listbox entry priority: first selected → last active (if still in list) → nearest the
|
|
42
|
+
// map center → first item (last resort, e.g. no items carry a screen position at all)
|
|
43
|
+
const resolveEntryId = (items, lastActiveId, selectedIds, centerScreenPoint) => {
|
|
44
|
+
const firstSelected = items.find(item => selectedIds.includes(item.id))
|
|
45
|
+
if (firstSelected) {
|
|
46
|
+
return firstSelected.id
|
|
47
|
+
}
|
|
48
|
+
if (lastActiveId && items.some(item => item.id === lastActiveId)) {
|
|
49
|
+
return lastActiveId
|
|
50
|
+
}
|
|
51
|
+
return findNearestToCenter(items, centerScreenPoint) ?? items[0].id
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Roving tabindex: moves real focus to the option matching id (via data-id, avoiding
|
|
55
|
+
// querySelector's CSS-escaping issues with arbitrary ids). Flags isInternalFocusMoveRef around
|
|
56
|
+
// the .focus() call so onFocus/onBlur can tell this apart from a real widget entry/exit.
|
|
57
|
+
const focusOption = (listboxEl, id, isInternalFocusMoveRef) => {
|
|
58
|
+
const el = Array.from(listboxEl?.children ?? []).find(li => li.dataset.id === String(id))
|
|
59
|
+
if (!el) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
isInternalFocusMoveRef.current = true
|
|
63
|
+
el.focus({ preventScroll: true })
|
|
64
|
+
isInternalFocusMoveRef.current = false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Keeps local selectedIds in sync with interact:selectionchange, and mirrors
|
|
69
|
+
* MAP_SET_ACTIVE_ITEM back into React state so the roving tabindex position stays current.
|
|
70
|
+
*/
|
|
71
|
+
function useEventBusListeners ({ eventBus, lastActiveIdRef, setActiveItemId, setSelectedIds }) {
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!eventBus) {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
const handleSetActive = ({ id }) => {
|
|
77
|
+
if (id !== null) {
|
|
78
|
+
lastActiveIdRef.current = id
|
|
79
|
+
}
|
|
80
|
+
setActiveItemId(id)
|
|
81
|
+
}
|
|
82
|
+
const handleSelectionChange = ({ selectedFeatures = [], selectedMarkers = [] }) => {
|
|
83
|
+
setSelectedIds([...selectedFeatures.map(f => String(f.featureId)), ...selectedMarkers])
|
|
84
|
+
}
|
|
85
|
+
eventBus.on(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
|
|
86
|
+
eventBus.on('interact:selectionchange', handleSelectionChange)
|
|
87
|
+
return () => {
|
|
88
|
+
eventBus.off(EVENTS.MAP_SET_ACTIVE_ITEM, handleSetActive)
|
|
89
|
+
eventBus.off('interact:selectionchange', handleSelectionChange)
|
|
90
|
+
}
|
|
91
|
+
}, [eventBus])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Re-picks the active item (ARIA priority order) when it drops out of the item list while
|
|
96
|
+
* focused — e.g. panned off screen — and moves real focus to it.
|
|
97
|
+
*/
|
|
98
|
+
function useItemsRevalidation ({ items, eventBus, isFocusedRef, spatialListRef, isInternalFocusMoveRef, lastActiveIdRef, activeItemIdRef, selectedIdsRef, setActiveItemId, centerScreenPoint }) {
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!isFocusedRef.current) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (!items.length) {
|
|
104
|
+
setActiveItemId(null)
|
|
105
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: null })
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (items.some(item => item.id === activeItemIdRef.current)) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const nextId = resolveEntryId(items, lastActiveIdRef.current, selectedIdsRef.current, centerScreenPoint)
|
|
112
|
+
lastActiveIdRef.current = nextId
|
|
113
|
+
setActiveItemId(nextId)
|
|
114
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: nextId })
|
|
115
|
+
focusOption(spatialListRef.current, nextId, isInternalFocusMoveRef)
|
|
116
|
+
}, [items]) // NOSONAR — eventBus/selectedIds consumed via refs to avoid spurious re-runs on selection change
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Attaches keydown/keyup listeners to the listbox element for ARIA keyboard navigation:
|
|
121
|
+
* - ArrowUp/ArrowDown — move the active item sequentially, moving real focus (roving tabindex)
|
|
122
|
+
* - Alt+Arrow (any of the four) — move the active item spatially, to whichever item is
|
|
123
|
+
* nearest in the pressed direction on screen, same roving-tabindex move as above
|
|
124
|
+
* - Home/End — jump the active item to the first/last option
|
|
125
|
+
* - Enter/Space — confirm selection, emitting MAP_SELECT_ITEM
|
|
126
|
+
* - Escape — return focus to the map viewport
|
|
127
|
+
*/
|
|
128
|
+
function useKeyboardNavigation ({ spatialListRef, viewportRef, items, eventBus, activeItemIdRef, lastActiveIdRef, setActiveItemId, isInternalFocusMoveRef, hints, currentHintRef }) {
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const listboxEl = spatialListRef.current
|
|
131
|
+
if (!listboxEl) {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
const moveTo = (newId) => {
|
|
135
|
+
lastActiveIdRef.current = newId
|
|
136
|
+
setActiveItemId(newId)
|
|
137
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: newId })
|
|
138
|
+
focusOption(listboxEl, newId, isInternalFocusMoveRef)
|
|
139
|
+
}
|
|
140
|
+
const handleEscape = () => {
|
|
141
|
+
if (currentHintRef.current) {
|
|
142
|
+
hints.dismiss()
|
|
143
|
+
} else {
|
|
144
|
+
viewportRef.current?.focus()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Same modifier as the map's own label navigation, but moves list items instead of labels
|
|
148
|
+
// while this listbox has focus — the keyup listener below stops it bubbling to that binding.
|
|
149
|
+
const handleSpatialMove = (event) => {
|
|
150
|
+
moveTo(findNearestItemInDirection(items, activeItemIdRef.current, event.key))
|
|
151
|
+
}
|
|
152
|
+
const handleKeyDown = (event) => {
|
|
153
|
+
if (event.key === 'Escape') {
|
|
154
|
+
event.preventDefault()
|
|
155
|
+
event.stopPropagation()
|
|
156
|
+
handleEscape()
|
|
157
|
+
} else if (event.altKey && ARROW_KEYS.has(event.key)) {
|
|
158
|
+
// preventDefault here (on keydown) suppresses the browser's own Alt+Arrow
|
|
159
|
+
// history-navigation default.
|
|
160
|
+
event.preventDefault()
|
|
161
|
+
event.stopPropagation()
|
|
162
|
+
handleSpatialMove(event)
|
|
163
|
+
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
|
|
164
|
+
event.preventDefault()
|
|
165
|
+
event.stopPropagation()
|
|
166
|
+
moveTo(getNavigatedId(activeItemIdRef.current, event.key, items))
|
|
167
|
+
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
168
|
+
event.preventDefault()
|
|
169
|
+
event.stopPropagation()
|
|
170
|
+
eventBus?.emit(EVENTS.MAP_SELECT_ITEM)
|
|
171
|
+
} else {
|
|
172
|
+
// No action
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Shadows global Alt+<key> shortcuts (src/utils/globalAltShortcuts.js) from bubbling to
|
|
176
|
+
// the app-wide handler while this listbox has focus.
|
|
177
|
+
const handleKeyUp = (event) => {
|
|
178
|
+
stopIfGlobalAltKey(event)
|
|
179
|
+
}
|
|
180
|
+
listboxEl.addEventListener('keydown', handleKeyDown)
|
|
181
|
+
listboxEl.addEventListener('keyup', handleKeyUp)
|
|
182
|
+
return () => {
|
|
183
|
+
listboxEl.removeEventListener('keydown', handleKeyDown)
|
|
184
|
+
listboxEl.removeEventListener('keyup', handleKeyUp)
|
|
185
|
+
}
|
|
186
|
+
}, [viewportRef, spatialListRef, items, eventBus])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Returns focus to the viewport when the user interacts with the map via pointer while the
|
|
191
|
+
* listbox is focused — clears its focus ring without dropping focus to nowhere.
|
|
192
|
+
*/
|
|
193
|
+
function useMapInteractionBlur ({ viewportRef, spatialListRef, isFocusedRef }) {
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const el = viewportRef.current
|
|
196
|
+
if (!el) {
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
const handlePointerDown = (event) => {
|
|
200
|
+
if (isFocusedRef.current && !spatialListRef.current?.contains(event.target)) {
|
|
201
|
+
viewportRef.current?.focus()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
el.addEventListener('pointerdown', handlePointerDown)
|
|
205
|
+
return () => { el.removeEventListener('pointerdown', handlePointerDown) }
|
|
206
|
+
}, [viewportRef, spatialListRef])
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Manages roving-tabindex focus state for the keyboard-accessible feature list. On focus, sets
|
|
211
|
+
* activeItemId via ARIA priority order (see resolveEntryId); on blur, clears it. Revalidates
|
|
212
|
+
* when the item list changes (e.g. after a map pan) so it never points to a stale item.
|
|
213
|
+
*
|
|
214
|
+
* @param {{ viewportRef: React.RefObject, spatialListRef: React.RefObject, items: Array, eventBus: object, centerScreenPoint: {x: number, y: number}|null }} params
|
|
215
|
+
* @returns {{ activeItemId: string|null, tabbableId: string|null, selectedIds: string[], onFocus: Function, onBlur: Function, selectItem: Function }}
|
|
216
|
+
*/
|
|
217
|
+
export function useSpatialListFocus ({ viewportRef, spatialListRef, items = [], eventBus, hints, centerScreenPoint }) {
|
|
218
|
+
const [activeItemId, setActiveItemId] = useState(null)
|
|
219
|
+
const [selectedIds, setSelectedIds] = useState([])
|
|
220
|
+
|
|
221
|
+
const isFocusedRef = useRef(false)
|
|
222
|
+
const lastActiveIdRef = useRef(null) // preserved across blur; restores position on re-focus
|
|
223
|
+
const activeItemIdRef = useRef(null) // always-current for keydown closure
|
|
224
|
+
const selectedIdsRef = useRef([]) // always-current for items-change effect
|
|
225
|
+
const currentHintRef = useRef(null)
|
|
226
|
+
const isInternalFocusMoveRef = useRef(false) // true only while focusOption()'s own .focus() call is in flight
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
return hints.subscribe((hint) => {
|
|
230
|
+
currentHintRef.current = hint
|
|
231
|
+
})
|
|
232
|
+
}, [hints])
|
|
233
|
+
|
|
234
|
+
// Always-current mirrors for values read from event-listener closures (not React re-renders) —
|
|
235
|
+
// assigned directly during render, same convention as useVisibleGeometry.js's latestRef.
|
|
236
|
+
activeItemIdRef.current = activeItemId
|
|
237
|
+
selectedIdsRef.current = selectedIds
|
|
238
|
+
|
|
239
|
+
useEventBusListeners({ eventBus, lastActiveIdRef, setActiveItemId, setSelectedIds })
|
|
240
|
+
useItemsRevalidation({ items, eventBus, isFocusedRef, spatialListRef, isInternalFocusMoveRef, lastActiveIdRef, activeItemIdRef, selectedIdsRef, setActiveItemId, centerScreenPoint })
|
|
241
|
+
useKeyboardNavigation({ spatialListRef, viewportRef, items, eventBus, activeItemIdRef, lastActiveIdRef, setActiveItemId, isInternalFocusMoveRef, hints, currentHintRef })
|
|
242
|
+
useMapInteractionBlur({ viewportRef, spatialListRef, isFocusedRef })
|
|
243
|
+
|
|
244
|
+
// Resting roving-tabindex position — where Tab lands before the list has ever had real focus.
|
|
245
|
+
// Deliberately never selection-driven (unlike onFocus's own resolution below): sticks to the
|
|
246
|
+
// last established keyboard position so an unrelated selection change elsewhere doesn't
|
|
247
|
+
// relocate it.
|
|
248
|
+
let tabbableId = null
|
|
249
|
+
if (items.length) {
|
|
250
|
+
const hasEstablishedPosition = lastActiveIdRef.current && items.some(item => item.id === lastActiveIdRef.current)
|
|
251
|
+
tabbableId = hasEstablishedPosition ? lastActiveIdRef.current : (findNearestToCenter(items, centerScreenPoint) ?? items[0].id)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const onFocus = () => {
|
|
255
|
+
isFocusedRef.current = true
|
|
256
|
+
// Ignore focus moving between sibling options (roving tabindex) — the keydown/selectItem
|
|
257
|
+
// path that moved it already resolved and emitted the correct id; re-resolving here would
|
|
258
|
+
// clobber that emit.
|
|
259
|
+
if (isInternalFocusMoveRef.current) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
if (!items.length) {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
// A real Tab-in prefers a selected item over the remembered position (native listbox
|
|
266
|
+
// behaviour) — unlike tabbableId, which deliberately ignores selection.
|
|
267
|
+
const id = resolveEntryId(items, lastActiveIdRef.current, selectedIds, centerScreenPoint)
|
|
268
|
+
lastActiveIdRef.current = id
|
|
269
|
+
setActiveItemId(id)
|
|
270
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id })
|
|
271
|
+
// Real focus may have landed on a different option than this resolves to (tabbableId and
|
|
272
|
+
// resolveEntryId can legitimately disagree) — move it to match so it never disagrees with activeItemId.
|
|
273
|
+
focusOption(spatialListRef.current, id, isInternalFocusMoveRef)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const onBlur = () => {
|
|
277
|
+
// Ignore focus moving between sibling options (roving tabindex) — not a real exit.
|
|
278
|
+
if (isInternalFocusMoveRef.current) {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
isFocusedRef.current = false
|
|
282
|
+
setActiveItemId(null)
|
|
283
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id: null })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Mirrors keyboard Enter/Space: make the clicked item active, focus it, then confirm selection.
|
|
287
|
+
const selectItem = (id) => {
|
|
288
|
+
lastActiveIdRef.current = id
|
|
289
|
+
setActiveItemId(id)
|
|
290
|
+
eventBus?.emit(EVENTS.MAP_SET_ACTIVE_ITEM, { id })
|
|
291
|
+
focusOption(spatialListRef.current, id, isInternalFocusMoveRef)
|
|
292
|
+
eventBus?.emit(EVENTS.MAP_SELECT_ITEM)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { activeItemId, tabbableId, selectedIds, onFocus, onBlur, selectItem }
|
|
296
|
+
}
|