@defra/interactive-map 0.0.37-alpha → 0.0.39-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/draw-tools.njk +4 -3
- 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.md +32 -0
- package/docs/examples/draw-tools.mdx +0 -4
- package/docs/plugins/draw.md +482 -0
- package/docs/plugins.md +2 -6
- package/govuk-prototype-kit.config.json +3 -2
- package/package.json +2 -4
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/css/index.css +1 -97
- 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/registry/dataset.js +3 -1
- package/plugins/datasets/src/registry/datasetRegistry.js +30 -24
- package/plugins/datasets/src/registry/datasetRegistry.test.js +25 -18
- package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -0
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
- package/plugins/draw/dist/esm/index.js +1 -0
- package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
- package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
- package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
- package/plugins/draw/dist/umd/index.js +2 -0
- package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
- package/plugins/draw/src/DrawInit.jsx +89 -0
- package/plugins/draw/src/DrawInit.test.jsx +184 -0
- package/plugins/draw/src/adapterEvents.js +127 -0
- package/plugins/draw/src/adapterEvents.test.js +20 -0
- package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
- package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
- package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
- package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
- package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
- package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
- package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
- package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
- package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
- package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
- package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
- package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
- package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
- package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
- package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
- package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
- package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
- package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
- package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
- package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
- package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
- package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
- package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
- package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
- package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
- package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
- package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
- package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
- package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
- package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
- package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
- package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
- package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
- package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
- package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
- package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
- package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
- package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
- package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
- package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
- package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
- package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
- package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
- package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
- package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
- package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
- package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
- package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
- package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
- package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
- package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
- package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
- package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
- package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
- package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
- package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
- package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
- package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
- package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
- package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
- package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
- package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
- package/plugins/draw/src/api/addFeature.js +22 -0
- package/plugins/draw/src/api/addFeature.test.js +41 -0
- package/plugins/draw/src/api/deleteFeature.js +11 -0
- package/plugins/draw/src/api/deleteFeature.test.js +24 -0
- package/plugins/draw/src/api/editFeature.js +54 -0
- package/plugins/draw/src/api/editFeature.test.js +134 -0
- package/plugins/draw/src/api/merge.js +30 -0
- package/plugins/draw/src/api/merge.test.js +57 -0
- package/plugins/draw/src/api/newLine.js +41 -0
- package/plugins/draw/src/api/newLine.test.js +93 -0
- package/plugins/draw/src/api/newPolygon.js +41 -0
- package/plugins/draw/src/api/newPolygon.test.js +93 -0
- package/plugins/draw/src/api/split.js +117 -0
- package/plugins/draw/src/api/split.test.js +204 -0
- package/plugins/draw/src/defaults.js +52 -0
- package/plugins/draw/src/defaults.test.js +18 -0
- package/plugins/draw/src/draw.scss +43 -0
- package/plugins/draw/src/events.js +237 -0
- package/plugins/draw/src/events.test.js +369 -0
- package/plugins/draw/src/index.js +10 -0
- package/plugins/draw/src/index.test.js +24 -0
- package/plugins/draw/src/manifest.js +170 -0
- package/plugins/draw/src/manifest.test.js +147 -0
- package/plugins/draw/src/reducer.js +69 -0
- package/plugins/draw/src/reducer.test.js +85 -0
- package/plugins/draw/src/utils/debounce.js +16 -0
- package/plugins/draw/src/utils/debounce.test.js +40 -0
- package/plugins/draw/src/utils/eventBus.js +36 -0
- package/plugins/draw/src/utils/eventBus.test.js +47 -0
- package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
- package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
- package/plugins/draw/src/utils/getValueForStyle.js +33 -0
- package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
- package/plugins/draw/src/utils/resolveColors.js +39 -0
- package/plugins/draw/src/utils/resolveColors.test.js +34 -0
- package/plugins/draw/src/utils/spatial.js +243 -0
- package/plugins/draw/src/utils/spatial.test.js +243 -0
- package/plugins/draw/src/utils/touchTarget.js +95 -0
- package/plugins/draw/src/utils/touchTarget.test.js +107 -0
- package/plugins/draw/src/utils/undoStack.js +31 -0
- package/plugins/draw/src/utils/undoStack.test.js +49 -0
- package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
- package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
- package/plugins/draw/src/validation/liveStroke.js +85 -0
- package/plugins/draw/src/validation/liveStroke.test.js +150 -0
- package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
- package/plugins/draw/src/validation/rules.js +186 -0
- package/plugins/draw/src/validation/rules.test.js +120 -0
- package/plugins/draw/src/validation/validateGeometry.js +119 -0
- package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
- 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/src/hooks/useInteractionHandlers.js +1 -1
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
- package/plugins/interact/src/utils/buildStylesMap.js +1 -0
- package/plugins/search/dist/css/index.css +1 -317
- package/rollup.esm.mjs +35 -28
- package/sonar-project.properties +2 -2
- package/src/App/components/Attributions/Attributions.module.scss +1 -0
- package/src/App/components/Hints/Hints.module.scss +5 -4
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
- package/src/App/components/MoveControl/MoveControl.jsx +73 -9
- package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
- package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
- package/src/App/hooks/useHintsAPI.js +67 -0
- package/src/App/hooks/useHintsAPI.test.js +145 -0
- package/src/App/renderer/PluginInits.jsx +4 -0
- package/src/App/renderer/PluginInits.test.jsx +7 -1
- package/src/InteractiveMap/InteractiveMap.js +20 -0
- package/src/InteractiveMap/InteractiveMap.test.js +7 -0
- package/src/config/events.js +4 -0
- package/src/scss/settings/_dimensions.scss +3 -0
- package/src/services/announcer.js +38 -3
- package/src/services/announcer.test.js +133 -1
- package/src/services/hints.js +3 -0
- package/src/services/hints.test.js +19 -0
- package/src/types.js +12 -0
- package/src/utils/detectInterfaceType.js +5 -1
- package/src/utils/isMac.js +15 -0
- package/src/utils/isMac.test.js +37 -0
- package/webpack.umd.mjs +1 -0
|
@@ -7,10 +7,10 @@ import { useService } from '../../store/serviceContext.js'
|
|
|
7
7
|
import { resolveStepAmount } from '../../../utils/resolveNudgeStep.js'
|
|
8
8
|
|
|
9
9
|
const DIRECTIONS = [
|
|
10
|
-
{ id: 'panUp', verb: 'up', dx: 0, dy: -1 },
|
|
11
|
-
{ id: 'panDown', verb: 'down', dx: 0, dy: 1 },
|
|
12
|
-
{ id: 'panLeft', verb: 'left', dx: -1, dy: 0 },
|
|
13
|
-
{ id: 'panRight', verb: 'right', dx: 1, dy: 0 }
|
|
10
|
+
{ id: 'panUp', verb: 'up', dx: 0, dy: -1, key: 'ArrowUp' },
|
|
11
|
+
{ id: 'panDown', verb: 'down', dx: 0, dy: 1, key: 'ArrowDown' },
|
|
12
|
+
{ id: 'panLeft', verb: 'left', dx: -1, dy: 0, key: 'ArrowLeft' },
|
|
13
|
+
{ id: 'panRight', verb: 'right', dx: 1, dy: 0, key: 'ArrowRight' }
|
|
14
14
|
]
|
|
15
15
|
|
|
16
16
|
const ZOOM_ACTIONS = [
|
|
@@ -20,7 +20,7 @@ const ZOOM_ACTIONS = [
|
|
|
20
20
|
|
|
21
21
|
export const MoveControl = () => {
|
|
22
22
|
const { id: appId, mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta } = useConfig()
|
|
23
|
-
const { dispatch, expandedButtons, nudgeStepSize } = useApp()
|
|
23
|
+
const { dispatch, expandedButtons, nudgeStepSize, interfaceType, layoutRefs } = useApp()
|
|
24
24
|
const { isAtMaxZoom, isAtMinZoom } = useMap()
|
|
25
25
|
const { announce } = useService()
|
|
26
26
|
|
|
@@ -38,16 +38,62 @@ export const MoveControl = () => {
|
|
|
38
38
|
// keyboard-shortcut vocabulary, so the label always describes the step size in effect.
|
|
39
39
|
const actionWord = isLargeStep ? 'Move' : 'Nudge'
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// Registry buttons (declared via a plugin's manifest) auto-return focus to the map
|
|
42
|
+
// viewport after a click (see createButtonClickHandler in mapButtons.js) unless
|
|
43
|
+
// marked keepFocus — which is how e.g. draw's own toolbar buttons never leave
|
|
44
|
+
// keyboard shortcuts (like arrow-key vertex nudging) stranded afterward. MoveControl
|
|
45
|
+
// renders its own buttons directly, bypassing that mechanism entirely, so it has to
|
|
46
|
+
// replicate the same return-to-viewport behaviour itself. But unlike a one-off
|
|
47
|
+
// action button, the D-pad is meant to be pressed repeatedly — a keyboard/switch
|
|
48
|
+
// user tabs to a direction button once and presses Enter/Space many times in a row,
|
|
49
|
+
// which requires focus to stay put. So this only returns focus when the interface
|
|
50
|
+
// type isn't 'keyboard': a mouse/touch user doesn't need retained focus to click the
|
|
51
|
+
// same visible button again, and returning it immediately means they can switch to
|
|
52
|
+
// arrow keys right after without an extra click into the map first.
|
|
53
|
+
const returnFocusToViewport = () => {
|
|
54
|
+
if (interfaceType !== 'keyboard') {
|
|
55
|
+
requestAnimationFrame(() => layoutRefs.viewportRef.current?.focus())
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handlePan = (dx, dy, verb, shiftKey = false) => {
|
|
60
|
+
// Shift is a momentary "use the small step" override — matching the map's own
|
|
61
|
+
// native keyboard shortcuts (keyboardActions.js's getPan/getZoom: "Shift held
|
|
62
|
+
// selects the fine nudge amount... not a large step") — regardless of what the
|
|
63
|
+
// Precision toggle currently says. Only the arrow-key path below passes a
|
|
64
|
+
// shiftKey; button clicks don't, so they're unaffected and still follow the
|
|
65
|
+
// toggle exactly as before. Button labels also stay toggle-only (WAI-ARIA
|
|
66
|
+
// stable-name pattern) — only the actual step taken and its announcement
|
|
67
|
+
// reflect a momentary Shift override.
|
|
68
|
+
const effectiveIsLargeStep = shiftKey ? false : isLargeStep
|
|
69
|
+
const effectiveActionWord = effectiveIsLargeStep ? 'Move' : 'Nudge'
|
|
70
|
+
|
|
71
|
+
// Any plugin can claim the D-pad for something other than panning the map (e.g.
|
|
72
|
+
// moving a selected draw vertex) by setting mapProvider.activeMoveTarget — a
|
|
73
|
+
// generic { move(dx, dy, isLargeStep), label? } contract. MoveControl doesn't
|
|
74
|
+
// know or care what's claiming it; it just checks for a claimant before
|
|
75
|
+
// defaulting to its own panBy behaviour. Read fresh on every click rather than
|
|
76
|
+
// subscribed to reactively, since it's a plain mapProvider property, not state.
|
|
77
|
+
const activeMoveTarget = mapProvider.activeMoveTarget
|
|
78
|
+
if (activeMoveTarget) {
|
|
79
|
+
activeMoveTarget.move(dx, dy, effectiveIsLargeStep)
|
|
80
|
+
const target = activeMoveTarget.label ? `${activeMoveTarget.label} ` : ''
|
|
81
|
+
announce(`${effectiveActionWord}d ${target}${verb}`)
|
|
82
|
+
returnFocusToViewport()
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const amount = resolveStepAmount(effectiveIsLargeStep, nudgePanDelta, panDelta)
|
|
43
87
|
mapProvider.panBy([dx * amount, dy * amount])
|
|
44
|
-
announce(`${
|
|
88
|
+
announce(`${effectiveActionWord}d ${verb}`)
|
|
89
|
+
returnFocusToViewport()
|
|
45
90
|
}
|
|
46
91
|
|
|
47
92
|
const handleZoom = (method, label) => {
|
|
48
93
|
const amount = resolveStepAmount(isLargeStep, nudgeZoomDelta, zoomDelta)
|
|
49
94
|
mapProvider[method](amount)
|
|
50
95
|
announce(label)
|
|
96
|
+
returnFocusToViewport()
|
|
51
97
|
}
|
|
52
98
|
|
|
53
99
|
const handleToggleStep = () => {
|
|
@@ -55,6 +101,24 @@ export const MoveControl = () => {
|
|
|
55
101
|
announce(isLargeStep ? 'Precision on' : 'Precision off')
|
|
56
102
|
}
|
|
57
103
|
|
|
104
|
+
// A keyboard user tabs to a direction button and can repeat-press Enter/Space on
|
|
105
|
+
// it (see returnFocusToViewport above — focus deliberately stays put for
|
|
106
|
+
// 'keyboard'), but that leaves them unable to fall back to raw arrow keys without
|
|
107
|
+
// first tabbing all the way back to the map. Handling arrow keys here — while
|
|
108
|
+
// focus is anywhere within this control, not just on a direction button — covers
|
|
109
|
+
// that directly, without needing draw's (or the app's own) keyboard-shortcut
|
|
110
|
+
// guards to special-case MoveControl's buttons. Only arrow keys are handled;
|
|
111
|
+
// other shortcuts (delete/escape/undo) still require focus on the viewport,
|
|
112
|
+
// which mouse/touch users already get back automatically after any click here.
|
|
113
|
+
const handleContainerKeyDown = (e) => {
|
|
114
|
+
const direction = DIRECTIONS.find(({ key }) => key === e.key)
|
|
115
|
+
if (!direction) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
e.preventDefault()
|
|
119
|
+
handlePan(direction.dx, direction.dy, direction.verb, e.shiftKey)
|
|
120
|
+
}
|
|
121
|
+
|
|
58
122
|
const containerClassName = [
|
|
59
123
|
'im-c-move-control',
|
|
60
124
|
!isOpen && 'im-c-move-control--collapsed'
|
|
@@ -105,7 +169,7 @@ export const MoveControl = () => {
|
|
|
105
169
|
)
|
|
106
170
|
|
|
107
171
|
return (
|
|
108
|
-
<div id={`${appId}-move-control-content`} className={containerClassName}>
|
|
172
|
+
<div id={`${appId}-move-control-content`} className={containerClassName} onKeyDown={handleContainerKeyDown}>{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */}
|
|
109
173
|
{directionsGroup}
|
|
110
174
|
{zoomGroup}
|
|
111
175
|
</div>
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
gap: var(--divider-gap);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
.im-c-move-control--collapsed {
|
|
15
|
+
.im-c-move-control.im-c-move-control--collapsed {
|
|
16
16
|
display: none;
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -99,7 +99,17 @@
|
|
|
99
99
|
// aria-expanded rather than aria-pressed — the trigger is correctly a disclosure
|
|
100
100
|
// button (it reveals the control below), not a toggle button, so it doesn't carry
|
|
101
101
|
// aria-pressed itself; the colour is purely a visual echo of its expanded state.
|
|
102
|
-
|
|
102
|
+
//
|
|
103
|
+
// Compound selector (base class + variant class), not the variant class alone —
|
|
104
|
+
// same reasoning as the --collapsed rule above. This selector's specificity was
|
|
105
|
+
// tied with .im-c-map-button:hover (MapButton.module.scss), both (0,2,0), so
|
|
106
|
+
// hovering the expanded trigger button was a coin-flip decided by cascade order —
|
|
107
|
+
// one cssnano mergeRules reshuffle in production and hover started winning,
|
|
108
|
+
// showing the hover tint instead of the pressed colours. The sibling
|
|
109
|
+
// .im-c-move-control .im-c-map-button[aria-pressed="true"] rule above never had
|
|
110
|
+
// this problem (it's already (0,3,0), safely ahead of hover); this one needed the
|
|
111
|
+
// same headroom.
|
|
112
|
+
.im-c-map-button.im-c-map-button--move-control[aria-expanded="true"] {
|
|
103
113
|
color: var(--pressed-button-foreground-color);
|
|
104
114
|
border-color: var(--pressed-button-border-color);
|
|
105
115
|
background-color: var(--pressed-button-background-color);
|
|
@@ -25,6 +25,7 @@ describe('MoveControl', () => {
|
|
|
25
25
|
dispatch,
|
|
26
26
|
expandedButtons: new Set(['moveControl']),
|
|
27
27
|
nudgeStepSize: 'large',
|
|
28
|
+
layoutRefs: { viewportRef: { current: { focus: jest.fn() } } },
|
|
28
29
|
...overrides
|
|
29
30
|
})
|
|
30
31
|
|
|
@@ -100,6 +101,31 @@ describe('MoveControl', () => {
|
|
|
100
101
|
expect(announce).toHaveBeenCalledWith('Nudged up')
|
|
101
102
|
})
|
|
102
103
|
|
|
104
|
+
it('routes direction clicks to mapProvider.activeMoveTarget instead of panning, when a plugin has claimed it', () => {
|
|
105
|
+
mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
|
|
106
|
+
render(<MoveControl />)
|
|
107
|
+
fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
|
|
108
|
+
expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(1, 0, true)
|
|
109
|
+
expect(mapProvider.panBy).not.toHaveBeenCalled()
|
|
110
|
+
expect(announce).toHaveBeenCalledWith('Moved vertex right')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('falls back to panning once activeMoveTarget is released', () => {
|
|
114
|
+
mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
|
|
115
|
+
const { rerender } = render(<MoveControl />)
|
|
116
|
+
mapProvider.activeMoveTarget = null
|
|
117
|
+
rerender(<MoveControl />)
|
|
118
|
+
fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
|
|
119
|
+
expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('omits the target label from the announcement when activeMoveTarget has none', () => {
|
|
123
|
+
mapProvider.activeMoveTarget = { move: jest.fn() }
|
|
124
|
+
render(<MoveControl />)
|
|
125
|
+
fireEvent.click(screen.getByRole('button', { name: 'Move up' }))
|
|
126
|
+
expect(announce).toHaveBeenCalledWith('Moved up')
|
|
127
|
+
})
|
|
128
|
+
|
|
103
129
|
it('zooms in and out by the large delta by default and announces the action', () => {
|
|
104
130
|
render(<MoveControl />)
|
|
105
131
|
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
|
@@ -139,6 +165,116 @@ describe('MoveControl', () => {
|
|
|
139
165
|
expect(mapProvider.zoomOut).not.toHaveBeenCalled()
|
|
140
166
|
})
|
|
141
167
|
|
|
168
|
+
describe('returning focus to the viewport after a click', () => {
|
|
169
|
+
let rafSpy
|
|
170
|
+
|
|
171
|
+
beforeEach(() => {
|
|
172
|
+
rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 })
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
afterEach(() => rafSpy.mockRestore())
|
|
176
|
+
|
|
177
|
+
it('returns focus to the viewport after panning on mouse/touch, so arrow-key shortcuts elsewhere are not left stranded on the D-pad button', () => {
|
|
178
|
+
const appState = buildAppState({ interfaceType: 'mouse' })
|
|
179
|
+
useApp.mockReturnValue(appState)
|
|
180
|
+
render(<MoveControl />)
|
|
181
|
+
|
|
182
|
+
fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
|
|
183
|
+
expect(appState.layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('keeps focus on the button when driven by keyboard, so repeated Enter/Space presses do not require re-tabbing', () => {
|
|
187
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
|
|
188
|
+
render(<MoveControl />)
|
|
189
|
+
fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
|
|
190
|
+
expect(rafSpy).not.toHaveBeenCalled()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('also returns focus after zooming on mouse/touch, but not on keyboard', () => {
|
|
194
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'mouse' }))
|
|
195
|
+
const { rerender } = render(<MoveControl />)
|
|
196
|
+
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
|
197
|
+
expect(rafSpy).toHaveBeenCalledTimes(1)
|
|
198
|
+
|
|
199
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
|
|
200
|
+
rerender(<MoveControl />)
|
|
201
|
+
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
|
202
|
+
expect(rafSpy).toHaveBeenCalledTimes(1)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('also returns focus after a vertex nudge via activeMoveTarget on mouse/touch', () => {
|
|
206
|
+
mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
|
|
207
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'touch' }))
|
|
208
|
+
render(<MoveControl />)
|
|
209
|
+
fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
|
|
210
|
+
expect(rafSpy).toHaveBeenCalledTimes(1)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('arrow keys while focus is anywhere within the control', () => {
|
|
215
|
+
// A keyboard user who tabs to a direction button and repeat-presses Enter keeps
|
|
216
|
+
// focus there (see the describe block above) — this lets them fall back to raw
|
|
217
|
+
// arrow keys without first tabbing all the way back out to the map.
|
|
218
|
+
it('pans the map on an arrow key, regardless of which button currently has focus', () => {
|
|
219
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
|
|
220
|
+
render(<MoveControl />)
|
|
221
|
+
// Focus a button unrelated to the direction being pressed, to prove this
|
|
222
|
+
// isn't just reading the focused button's own handler.
|
|
223
|
+
fireEvent.focus(screen.getByRole('button', { name: 'Zoom in' }))
|
|
224
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Zoom in' }), { key: 'ArrowRight' })
|
|
225
|
+
expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
|
|
226
|
+
expect(announce).toHaveBeenCalledWith('Moved right')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('routes the arrow key through activeMoveTarget when a plugin has claimed the control', () => {
|
|
230
|
+
mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
|
|
231
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
|
|
232
|
+
render(<MoveControl />)
|
|
233
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp' })
|
|
234
|
+
expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, true)
|
|
235
|
+
expect(mapProvider.panBy).not.toHaveBeenCalled()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('ignores non-arrow keys, leaving default behaviour (e.g. Enter/Space activating the focused button) untouched', () => {
|
|
239
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
|
|
240
|
+
render(<MoveControl />)
|
|
241
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'Enter' })
|
|
242
|
+
expect(mapProvider.panBy).not.toHaveBeenCalled()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('shift+arrow overrides the Precision toggle to the small step, matching the map\'s own native keyboard shortcuts', () => {
|
|
246
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
|
|
247
|
+
render(<MoveControl />)
|
|
248
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight', shiftKey: true })
|
|
249
|
+
expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
|
|
250
|
+
expect(announce).toHaveBeenCalledWith('Nudged right')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('shift+arrow still resolves to the small step when Precision is already on (idempotent, not a toggle-relative flip)', () => {
|
|
254
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'small' }))
|
|
255
|
+
render(<MoveControl />)
|
|
256
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Nudge right' }), { key: 'ArrowRight', shiftKey: true })
|
|
257
|
+
expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
|
|
258
|
+
expect(announce).toHaveBeenCalledWith('Nudged right')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('shift+arrow overrides activeMoveTarget.move to the small step too', () => {
|
|
262
|
+
mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
|
|
263
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
|
|
264
|
+
render(<MoveControl />)
|
|
265
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp', shiftKey: true })
|
|
266
|
+
expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, false)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('arrow key without shift still follows the Precision toggle as before', () => {
|
|
270
|
+
useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
|
|
271
|
+
render(<MoveControl />)
|
|
272
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight' })
|
|
273
|
+
expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
|
|
274
|
+
expect(announce).toHaveBeenCalledWith('Moved right')
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
142
278
|
it('has a stable "Precision" label regardless of state', () => {
|
|
143
279
|
const { rerender } = render(<MoveControl />)
|
|
144
280
|
expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { EVENTS as events } from '../../config/events.js'
|
|
3
|
+
import { useService } from '../store/serviceContext.js'
|
|
4
|
+
import { useApp } from '../store/appContext.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wires the public showHint()/dismissHint() API onto the hints service.
|
|
8
|
+
* The hints service already owns its own subscriber list (see Hints.jsx),
|
|
9
|
+
* so this just forwards eventBus commands to it — no React state involved.
|
|
10
|
+
*/
|
|
11
|
+
export const useHintsAPI = () => {
|
|
12
|
+
const { eventBus, hints } = useService()
|
|
13
|
+
const { layoutRefs } = useApp()
|
|
14
|
+
const isHintActiveRef = useRef(false)
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const handleShowHint = ({ text, options } = {}) => {
|
|
18
|
+
if (!text) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
hints.show(text, options)
|
|
22
|
+
}
|
|
23
|
+
const handleDismissHint = () => hints.dismiss()
|
|
24
|
+
|
|
25
|
+
eventBus.on(events.APP_SHOW_HINT, handleShowHint)
|
|
26
|
+
eventBus.on(events.APP_DISMISS_HINT, handleDismissHint)
|
|
27
|
+
|
|
28
|
+
return () => {
|
|
29
|
+
eventBus.off(events.APP_SHOW_HINT, handleShowHint)
|
|
30
|
+
eventBus.off(events.APP_DISMISS_HINT, handleDismissHint)
|
|
31
|
+
}
|
|
32
|
+
}, [eventBus, hints])
|
|
33
|
+
|
|
34
|
+
// Escape dismisses the active hint when the keypress originates inside this
|
|
35
|
+
// map instance. The viewport and features listbox already dismiss hints on
|
|
36
|
+
// Escape within their own narrower focus scope (useKeyboardHint.js /
|
|
37
|
+
// useFeatureFocus.js) — this covers hints shown via the public showHint()
|
|
38
|
+
// API from anywhere else in the same map's UI (e.g. a plugin button).
|
|
39
|
+
//
|
|
40
|
+
// Listening on document (rather than the map's own container) and checking
|
|
41
|
+
// containment ourselves, rather than a container-scoped listener, is
|
|
42
|
+
// deliberate: with multiple map instances on one page, each has its own
|
|
43
|
+
// hints service and its own isHintActiveRef, so without the containment
|
|
44
|
+
// check an Escape press anywhere on the host page would dismiss a hint on
|
|
45
|
+
// every instance that happened to have one showing — including maps the
|
|
46
|
+
// user isn't even looking at.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
return hints.subscribe((hint) => {
|
|
49
|
+
isHintActiveRef.current = Boolean(hint)
|
|
50
|
+
})
|
|
51
|
+
}, [hints])
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const handleKeyDown = (e) => {
|
|
55
|
+
if (e.key !== 'Escape' || !isHintActiveRef.current) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const container = layoutRefs.appContainerRef?.current
|
|
59
|
+
if (container && !container.contains(e.target)) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
hints.dismiss()
|
|
63
|
+
}
|
|
64
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
65
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
66
|
+
}, [hints, layoutRefs])
|
|
67
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { useHintsAPI } from './useHintsAPI.js'
|
|
3
|
+
import { useService } from '../store/serviceContext.js'
|
|
4
|
+
import { useApp } from '../store/appContext.js'
|
|
5
|
+
|
|
6
|
+
jest.mock('../store/serviceContext.js')
|
|
7
|
+
jest.mock('../store/appContext.js')
|
|
8
|
+
|
|
9
|
+
const makeEventBus = () => {
|
|
10
|
+
const handlers = {}
|
|
11
|
+
return {
|
|
12
|
+
on: jest.fn((event, handler) => { handlers[event] = handler }),
|
|
13
|
+
off: jest.fn(),
|
|
14
|
+
emit: (event, payload) => handlers[event]?.(payload),
|
|
15
|
+
_handlers: handlers
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const makeHints = () => {
|
|
20
|
+
let subscriber = null
|
|
21
|
+
return {
|
|
22
|
+
show: jest.fn(),
|
|
23
|
+
dismiss: jest.fn(),
|
|
24
|
+
subscribe: jest.fn((fn) => {
|
|
25
|
+
subscriber = fn
|
|
26
|
+
return () => { subscriber = null }
|
|
27
|
+
}),
|
|
28
|
+
_emit: (hint) => subscriber?.(hint)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pressEscape = (target) => {
|
|
33
|
+
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('useHintsAPI', () => {
|
|
37
|
+
let mockEventBus, mockHints, containerEl, insideEl, outsideEl
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mockEventBus = makeEventBus()
|
|
41
|
+
mockHints = makeHints()
|
|
42
|
+
useService.mockReturnValue({ eventBus: mockEventBus, hints: mockHints })
|
|
43
|
+
|
|
44
|
+
// Simulates this map instance's root DOM (layoutRefs.appContainerRef),
|
|
45
|
+
// plus an element outside it — standing in for a second map instance,
|
|
46
|
+
// or unrelated content elsewhere on the host page.
|
|
47
|
+
containerEl = document.createElement('div')
|
|
48
|
+
insideEl = document.createElement('button')
|
|
49
|
+
containerEl.appendChild(insideEl)
|
|
50
|
+
document.body.appendChild(containerEl)
|
|
51
|
+
|
|
52
|
+
outsideEl = document.createElement('button')
|
|
53
|
+
document.body.appendChild(outsideEl)
|
|
54
|
+
|
|
55
|
+
useApp.mockReturnValue({ layoutRefs: { appContainerRef: { current: containerEl } } })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
containerEl.remove()
|
|
60
|
+
outsideEl.remove()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('calls hints.show with text and options on app:showhint', () => {
|
|
64
|
+
renderHook(() => useHintsAPI())
|
|
65
|
+
act(() => mockEventBus.emit('app:showhint', { text: 'Press Enter to select', options: { duration: 2000 } }))
|
|
66
|
+
expect(mockHints.show).toHaveBeenCalledWith('Press Enter to select', { duration: 2000 })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('ignores app:showhint with no text', () => {
|
|
70
|
+
renderHook(() => useHintsAPI())
|
|
71
|
+
act(() => mockEventBus.emit('app:showhint', {}))
|
|
72
|
+
act(() => mockEventBus.emit('app:showhint'))
|
|
73
|
+
expect(mockHints.show).not.toHaveBeenCalled()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('calls hints.dismiss on app:dismisshint', () => {
|
|
77
|
+
renderHook(() => useHintsAPI())
|
|
78
|
+
act(() => mockEventBus.emit('app:dismisshint'))
|
|
79
|
+
expect(mockHints.dismiss).toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('unsubscribes on unmount', () => {
|
|
83
|
+
const { unmount } = renderHook(() => useHintsAPI())
|
|
84
|
+
unmount()
|
|
85
|
+
expect(mockEventBus.off).toHaveBeenCalledWith('app:showhint', expect.any(Function))
|
|
86
|
+
expect(mockEventBus.off).toHaveBeenCalledWith('app:dismisshint', expect.any(Function))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('Escape key', () => {
|
|
90
|
+
it('dismisses the active hint when Escape originates inside this map instance', () => {
|
|
91
|
+
renderHook(() => useHintsAPI())
|
|
92
|
+
act(() => mockHints._emit({ html: 'Press Enter to select' }))
|
|
93
|
+
|
|
94
|
+
act(() => pressEscape(insideEl))
|
|
95
|
+
expect(mockHints.dismiss).toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('does not dismiss when Escape originates outside this map instance', () => {
|
|
99
|
+
// e.g. a second map instance on the page, or unrelated host-page content
|
|
100
|
+
renderHook(() => useHintsAPI())
|
|
101
|
+
act(() => mockHints._emit({ html: 'Press Enter to select' }))
|
|
102
|
+
|
|
103
|
+
act(() => pressEscape(outsideEl))
|
|
104
|
+
expect(mockHints.dismiss).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does nothing on Escape when no hint is showing', () => {
|
|
108
|
+
renderHook(() => useHintsAPI())
|
|
109
|
+
act(() => pressEscape(insideEl))
|
|
110
|
+
expect(mockHints.dismiss).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('stops reacting to Escape once the hint has been dismissed', () => {
|
|
114
|
+
renderHook(() => useHintsAPI())
|
|
115
|
+
act(() => mockHints._emit({ html: 'Press Enter to select' }))
|
|
116
|
+
act(() => mockHints._emit(null)) // hints service reports no active hint
|
|
117
|
+
|
|
118
|
+
act(() => pressEscape(insideEl))
|
|
119
|
+
expect(mockHints.dismiss).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('falls back to dismissing when appContainerRef is not yet available', () => {
|
|
123
|
+
useApp.mockReturnValue({ layoutRefs: { appContainerRef: { current: null } } })
|
|
124
|
+
renderHook(() => useHintsAPI())
|
|
125
|
+
act(() => mockHints._emit({ html: 'Press Enter to select' }))
|
|
126
|
+
|
|
127
|
+
act(() => pressEscape(outsideEl))
|
|
128
|
+
expect(mockHints.dismiss).toHaveBeenCalled()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('removes the keydown listener on unmount', () => {
|
|
132
|
+
const addSpy = jest.spyOn(document, 'addEventListener')
|
|
133
|
+
const removeSpy = jest.spyOn(document, 'removeEventListener')
|
|
134
|
+
|
|
135
|
+
const { unmount } = renderHook(() => useHintsAPI())
|
|
136
|
+
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
137
|
+
|
|
138
|
+
unmount()
|
|
139
|
+
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
140
|
+
|
|
141
|
+
addSpy.mockRestore()
|
|
142
|
+
removeSpy.mockRestore()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -3,6 +3,7 @@ import React, { useEffect } from 'react'
|
|
|
3
3
|
import { withPluginContexts } from './pluginWrapper.js'
|
|
4
4
|
import { withPluginApiContexts, usePluginApiState } from './pluginApiWrapper.js'
|
|
5
5
|
import { useInterfaceAPI } from '../hooks/useInterfaceAPI.js'
|
|
6
|
+
import { useHintsAPI } from '../hooks/useHintsAPI.js'
|
|
6
7
|
import { useApp } from '../store/appContext.js'
|
|
7
8
|
import { useConfig } from '../store/configContext.js'
|
|
8
9
|
import { useEvaluateProp } from '../hooks/useEvaluateProp.js'
|
|
@@ -53,6 +54,9 @@ export const PluginInits = () => {
|
|
|
53
54
|
// Add button, panel and control API methods (Needs to be top-level)
|
|
54
55
|
useInterfaceAPI()
|
|
55
56
|
|
|
57
|
+
// Wire the showHint()/dismissHint() public API onto the hints service
|
|
58
|
+
useHintsAPI()
|
|
59
|
+
|
|
56
60
|
// Evaluate reactive button states globally
|
|
57
61
|
const evaluateProp = useEvaluateProp()
|
|
58
62
|
useButtonStateEvaluator(evaluateProp)
|
|
@@ -6,6 +6,7 @@ import { useButtonStateEvaluator } from '../hooks/useButtonStateEvaluator.js'
|
|
|
6
6
|
import { withPluginApiContexts } from './pluginApiWrapper.js'
|
|
7
7
|
import { withPluginContexts } from './pluginWrapper.js'
|
|
8
8
|
import { useInterfaceAPI } from '../hooks/useInterfaceAPI.js'
|
|
9
|
+
import { useHintsAPI } from '../hooks/useHintsAPI.js'
|
|
9
10
|
import { useApp } from '../store/appContext.js'
|
|
10
11
|
import { useConfig } from '../store/configContext.js'
|
|
11
12
|
|
|
@@ -29,6 +30,10 @@ jest.mock('../hooks/useInterfaceAPI.js', () => ({
|
|
|
29
30
|
useInterfaceAPI: jest.fn()
|
|
30
31
|
}))
|
|
31
32
|
|
|
33
|
+
jest.mock('../hooks/useHintsAPI.js', () => ({
|
|
34
|
+
useHintsAPI: jest.fn()
|
|
35
|
+
}))
|
|
36
|
+
|
|
32
37
|
jest.mock('../hooks/useEvaluateProp.js', () => ({
|
|
33
38
|
useEvaluateProp: jest.fn(() => (x) => x)
|
|
34
39
|
}))
|
|
@@ -59,10 +64,11 @@ describe('PluginInits', () => {
|
|
|
59
64
|
useConfig.mockReturnValue({ pluginRegistry: pluginRegistryMock })
|
|
60
65
|
})
|
|
61
66
|
|
|
62
|
-
it('calls useButtonStateEvaluator and
|
|
67
|
+
it('calls useButtonStateEvaluator, useInterfaceAPI and useHintsAPI on render', () => {
|
|
63
68
|
render(<PluginInits />)
|
|
64
69
|
expect(useButtonStateEvaluator).toHaveBeenCalled()
|
|
65
70
|
expect(useInterfaceAPI).toHaveBeenCalled()
|
|
71
|
+
expect(useHintsAPI).toHaveBeenCalled()
|
|
66
72
|
})
|
|
67
73
|
|
|
68
74
|
it('renders nothing when no plugins registered', () => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @typedef {import('../types.js').ButtonDefinition} ButtonDefinition
|
|
5
5
|
* @typedef {import('../types.js').ControlDefinition} ControlDefinition
|
|
6
|
+
* @typedef {import('../types.js').HintOptions} HintOptions
|
|
6
7
|
* @typedef {import('../types.js').InteractiveMapConfig} InteractiveMapConfig
|
|
7
8
|
* @typedef {import('../types.js').MarkerOptions} MarkerOptions
|
|
8
9
|
* @typedef {import('../types.js').PanelDefinition} PanelDefinition
|
|
@@ -481,6 +482,25 @@ export default class InteractiveMap {
|
|
|
481
482
|
this.eventBus.emit(events.APP_ADD_CONTROL, { id, config })
|
|
482
483
|
}
|
|
483
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Show a toast hint, announced to screen readers via the live region.
|
|
487
|
+
* Replaces any hint currently showing and restarts its dismiss timer.
|
|
488
|
+
*
|
|
489
|
+
* @param {string} text - Hint text. May contain simple HTML (e.g. `<kbd>`).
|
|
490
|
+
* @param {HintOptions} [options] - Optional hint behaviour.
|
|
491
|
+
*/
|
|
492
|
+
showHint (text, options) {
|
|
493
|
+
this.eventBus.emit(events.APP_SHOW_HINT, { text, options })
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Dismiss the active toast hint, if any. No-op if no hint is showing.
|
|
498
|
+
* Mainly useful for hints shown with `{ duration: 0 }`, which otherwise persist indefinitely.
|
|
499
|
+
*/
|
|
500
|
+
dismissHint () {
|
|
501
|
+
this.eventBus.emit(events.APP_DISMISS_HINT)
|
|
502
|
+
}
|
|
503
|
+
|
|
484
504
|
/**
|
|
485
505
|
* Fit the map view to a bounding box or GeoJSON geometry, respecting the safe zone padding.
|
|
486
506
|
*
|
|
@@ -579,4 +579,11 @@ describe('InteractiveMap — Public API Methods', () => {
|
|
|
579
579
|
expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:fittobounds', bbox)
|
|
580
580
|
expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:setview', { center, zoom: 12 })
|
|
581
581
|
})
|
|
582
|
+
|
|
583
|
+
it('showHint and dismissHint emit correct events', () => {
|
|
584
|
+
map.showHint('Press <kbd>Enter</kbd> to select', { duration: 2000 })
|
|
585
|
+
map.dismissHint()
|
|
586
|
+
expect(map.eventBus.emit).toHaveBeenCalledWith('app:showhint', { text: 'Press <kbd>Enter</kbd> to select', options: { duration: 2000 } })
|
|
587
|
+
expect(map.eventBus.emit).toHaveBeenCalledWith('app:dismisshint')
|
|
588
|
+
})
|
|
582
589
|
})
|
package/src/config/events.js
CHANGED
|
@@ -47,6 +47,10 @@ export const EVENTS = {
|
|
|
47
47
|
APP_HIDE_PANEL: 'app:hidepanel',
|
|
48
48
|
/** @internal Add a control. Payload: { id, config } */
|
|
49
49
|
APP_ADD_CONTROL: 'app:addcontrol',
|
|
50
|
+
/** @internal Show a toast hint, announced to screen readers. Payload: { text, options } */
|
|
51
|
+
APP_SHOW_HINT: 'app:showhint',
|
|
52
|
+
/** @internal Dismiss the active toast hint, if any. */
|
|
53
|
+
APP_DISMISS_HINT: 'app:dismisshint',
|
|
50
54
|
|
|
51
55
|
// ============================================
|
|
52
56
|
// App responses (end-user / subscribe)
|