@defra/interactive-map 0.0.43-fmp-experimental → 0.0.45-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 +11 -7
- package/assets/templates/add-symbols.njk +5 -1
- package/assets/templates/select-feature.njk +72 -15
- 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/examples/add-marker-with-panel.mdx +4 -0
- package/docs/examples/add-polygons.mdx +16 -12
- package/docs/examples/add-symbols.mdx +4 -0
- package/docs/examples/basic-map.mdx +4 -0
- package/docs/examples/button-map.mdx +4 -0
- package/docs/examples/draw-tools.mdx +4 -0
- package/docs/examples/place-marker.mdx +4 -0
- package/docs/examples/search-control.mdx +4 -0
- package/docs/examples/select-feature.mdx +4 -0
- package/docs/examples/style-switcher.mdx +36 -44
- package/docs/examples/toggle-marker-label.mdx +4 -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/components/Form/Form.jsx +3 -3
- package/plugins/search/src/components/Suggestions/Suggestions.jsx +2 -2
- package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +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/esri/dist/esm/im-esri-provider.js +1 -1
- package/providers/beta/esri/src/utils/spatial.js +15 -13
- 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/beta/openlayers/src/utils/spatial.js +16 -14
- package/providers/beta/openlayers/src/utils/spatial.test.js +10 -6
- 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/maplibreProvider.js +1 -1
- 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 +19 -74
- package/providers/maplibre/src/utils/spatial.test.js +8 -32
- 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 +9 -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 +17 -17
- 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/scss/tools/_govuk-content.scss +29 -0
- package/src/scss/tools/_index.scss +2 -1
- package/src/test-utils.js +8 -0
- package/src/types.js +53 -11
- 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
|
@@ -21,7 +21,8 @@ const MenuItem = ({ item, isSelected, hiddenButtons, disabledButtons, pressedBut
|
|
|
21
21
|
</li>
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// isOpen defaults to true so direct/standalone usage doesn't need to pass it.
|
|
25
|
+
export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect, isOpen = true }) => {
|
|
25
26
|
const { id } = useConfig()
|
|
26
27
|
const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons, layoutRefs } = useApp()
|
|
27
28
|
const instigatorKey = buttonId ?? instigatorId
|
|
@@ -43,7 +44,8 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
|
|
|
43
44
|
id,
|
|
44
45
|
menuRef,
|
|
45
46
|
setIsOpen,
|
|
46
|
-
buttonRect
|
|
47
|
+
buttonRect,
|
|
48
|
+
isOpen
|
|
47
49
|
})
|
|
48
50
|
|
|
49
51
|
return createPortal(
|
|
@@ -56,11 +58,13 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
|
|
|
56
58
|
style={menuStyle}
|
|
57
59
|
role='menu' // NOSONAR
|
|
58
60
|
tabIndex='-1'
|
|
61
|
+
hidden={!isOpen}
|
|
59
62
|
aria-labelledby={instigatorKey}
|
|
60
63
|
aria-activedescendant={index >= 0 ? `${id}-${stringToKebab(items[index].id)}` : undefined}
|
|
61
64
|
onKeyDown={handleMenuKeyDown}
|
|
62
65
|
>
|
|
63
|
-
{
|
|
66
|
+
{/* Items only mount once open — the <ul> above stays in the DOM either way. */}
|
|
67
|
+
{isOpen && items.map((item, i) => (
|
|
64
68
|
<MenuItem
|
|
65
69
|
key={item.id}
|
|
66
70
|
item={item}
|
|
@@ -651,4 +651,75 @@ describe('PopupMenu', () => {
|
|
|
651
651
|
window.dispatchEvent(new Event('resize'))
|
|
652
652
|
expect(mockSetIsOpen).toHaveBeenCalledWith(false)
|
|
653
653
|
})
|
|
654
|
+
|
|
655
|
+
describe('isOpen (always-mounted, hidden-toggle)', () => {
|
|
656
|
+
it('renders a hidden, empty menu when isOpen is false, without grabbing focus or item content', () => {
|
|
657
|
+
renderMenu({ isOpen: false })
|
|
658
|
+
const ul = screen.getByRole('menu', { hidden: true })
|
|
659
|
+
expect(ul).toHaveAttribute('hidden')
|
|
660
|
+
expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
|
|
661
|
+
expect(document.activeElement).not.toBe(ul)
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('keeps the same ul node across an isOpen transition (no swap/remount) and un-hides it once open', () => {
|
|
665
|
+
const { rerender } = renderMenu({ isOpen: false })
|
|
666
|
+
const ulWhileClosed = screen.getByRole('menu', { hidden: true })
|
|
667
|
+
|
|
668
|
+
rerender(
|
|
669
|
+
<PopupMenu
|
|
670
|
+
id='menu'
|
|
671
|
+
pluginId='plugin1'
|
|
672
|
+
instigatorId='instigator'
|
|
673
|
+
startPos='first'
|
|
674
|
+
menuRef={menuRef}
|
|
675
|
+
items={items}
|
|
676
|
+
setIsOpen={mockSetIsOpen}
|
|
677
|
+
isOpen
|
|
678
|
+
/>
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
const ulWhileOpen = screen.getByRole('menu')
|
|
682
|
+
expect(ulWhileOpen).toBe(ulWhileClosed)
|
|
683
|
+
expect(ulWhileOpen).not.toHaveAttribute('hidden')
|
|
684
|
+
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
|
685
|
+
expect(document.activeElement).toBe(ulWhileOpen)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
it('resets selection on every open rather than carrying over a stale index from a previous open', () => {
|
|
689
|
+
const { rerender } = renderMenu({ startPos: 'first', startIndex: undefined })
|
|
690
|
+
expectSelected('Item 1')
|
|
691
|
+
|
|
692
|
+
// Close, then reopen via a plain click — selection should reset, not carry over.
|
|
693
|
+
rerender(
|
|
694
|
+
<PopupMenu
|
|
695
|
+
id='menu'
|
|
696
|
+
pluginId='plugin1'
|
|
697
|
+
instigatorId='instigator'
|
|
698
|
+
menuRef={menuRef}
|
|
699
|
+
items={items}
|
|
700
|
+
setIsOpen={mockSetIsOpen}
|
|
701
|
+
isOpen={false}
|
|
702
|
+
/>
|
|
703
|
+
)
|
|
704
|
+
rerender(
|
|
705
|
+
<PopupMenu
|
|
706
|
+
id='menu'
|
|
707
|
+
pluginId='plugin1'
|
|
708
|
+
instigatorId='instigator'
|
|
709
|
+
menuRef={menuRef}
|
|
710
|
+
items={items}
|
|
711
|
+
setIsOpen={mockSetIsOpen}
|
|
712
|
+
isOpen
|
|
713
|
+
/>
|
|
714
|
+
)
|
|
715
|
+
expectNotSelected('Item 1')
|
|
716
|
+
expectNotSelected('Item 2')
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
it('defaults isOpen to true for direct/standalone usage', () => {
|
|
720
|
+
renderMenu()
|
|
721
|
+
expect(screen.getByRole('menu')).not.toHaveAttribute('hidden')
|
|
722
|
+
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
|
723
|
+
})
|
|
724
|
+
})
|
|
654
725
|
})
|
|
@@ -275,12 +275,14 @@ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disa
|
|
|
275
275
|
* @param {object} params.menuRef - Ref to the menu UL element.
|
|
276
276
|
* @param {Function} params.setIsOpen - Callback to open/close the menu.
|
|
277
277
|
* @param {DOMRect} params.buttonRect - Bounding rect of the trigger button for positioning.
|
|
278
|
+
* @param {boolean} [params.isOpen] - Whether the menu is currently open (PopupMenu stays
|
|
279
|
+
* mounted; this just toggles visibility, so setup/teardown here is keyed on isOpen, not mount).
|
|
278
280
|
* @returns {{ index: number, handleMenuKeyDown: Function, handleItemClick: Function,
|
|
279
281
|
* menuStyle: object, menuDirection: string, menuHAlign: string }}
|
|
280
282
|
*/
|
|
281
283
|
export const usePopupMenu = ({
|
|
282
284
|
items, hiddenButtons, startIndex, startPos, instigator, instigatorKey,
|
|
283
|
-
buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect
|
|
285
|
+
buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect, isOpen
|
|
284
286
|
}) => {
|
|
285
287
|
const { dispatch, layoutRefs } = useApp()
|
|
286
288
|
const viewportRef = layoutRefs.viewportRef
|
|
@@ -329,15 +331,14 @@ export const usePopupMenu = ({
|
|
|
329
331
|
}
|
|
330
332
|
}
|
|
331
333
|
|
|
334
|
+
// Keyed on isOpen (not mount) since PopupMenu stays mounted; resolveInitialIndex re-runs each
|
|
335
|
+
// open so selection resets instead of carrying over from a previous open.
|
|
332
336
|
useEffect(() => {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
setIndex(visibleIndices[0] ?? -1)
|
|
336
|
-
} else if (startPos === 'last') {
|
|
337
|
-
setIndex(visibleIndices[visibleIndices.length - 1] ?? -1) // NOSONAR .length - 1 used instead of .at(-1) for wider browser support
|
|
338
|
-
} else {
|
|
339
|
-
// No action
|
|
337
|
+
if (!isOpen) {
|
|
338
|
+
return undefined
|
|
340
339
|
}
|
|
340
|
+
menuRef.current?.focus()
|
|
341
|
+
setIndex(resolveInitialIndex(startIndex, startPos, visibleIndices))
|
|
341
342
|
const handleResize = () => setIsOpen(false)
|
|
342
343
|
document.addEventListener('focusin', handleOutside)
|
|
343
344
|
document.addEventListener('pointerdown', handleOutside)
|
|
@@ -347,7 +348,7 @@ export const usePopupMenu = ({
|
|
|
347
348
|
document.removeEventListener('pointerdown', handleOutside)
|
|
348
349
|
window.removeEventListener('resize', handleResize)
|
|
349
350
|
}
|
|
350
|
-
}, [])
|
|
351
|
+
}, [isOpen])
|
|
351
352
|
|
|
352
353
|
const { style: menuStyle, direction: menuDirection, halign: menuHAlign } = getMenuStyle(buttonRect)
|
|
353
354
|
return { index, handleMenuKeyDown, handleItemClick, menuStyle, menuDirection, menuHAlign }
|
|
@@ -11,7 +11,7 @@ export const MapStatus = () => {
|
|
|
11
11
|
<div // NOSONAR: div + status role is semantically correct here, <output> implies a calculation result
|
|
12
12
|
ref={mapStatusRef}
|
|
13
13
|
role='status'
|
|
14
|
-
className='im-
|
|
14
|
+
className='im-u-visually-hidden'
|
|
15
15
|
/>
|
|
16
16
|
)
|
|
17
17
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React, { forwardRef } from 'react'
|
|
2
|
+
import { useConfig } from '../../store/configContext.js'
|
|
3
|
+
|
|
4
|
+
// Accessible spatial list: a listbox whose items are positioned to match wherever they are on
|
|
5
|
+
// the map, navigable both sequentially (Arrow/Home/End) and spatially (Alt+Arrow — see
|
|
6
|
+
// useSpatialListFocus.js). Content is whatever the current spatialListRegistry provider
|
|
7
|
+
// contributes; `label` names it for aria-label accordingly.
|
|
8
|
+
export const SpatialList = forwardRef(({ activeItemId, tabbableId, selectedIds = [], multiselectable = false, items = [], label = 'Map features', focusable = true, onFocus, onBlur, onSelectItem }, ref) => {
|
|
9
|
+
const { id } = useConfig()
|
|
10
|
+
const hasItems = items.length > 0
|
|
11
|
+
// Roving tabindex: exactly one option is a Tab stop at a time — see useSpatialListFocus.js.
|
|
12
|
+
const currentId = activeItemId ?? tabbableId
|
|
13
|
+
return (
|
|
14
|
+
<ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
|
|
15
|
+
id={`${id}-spatial-list`}
|
|
16
|
+
ref={ref}
|
|
17
|
+
role='listbox' // NOSONAR
|
|
18
|
+
aria-hidden={hasItems ? undefined : true}
|
|
19
|
+
aria-label={label}
|
|
20
|
+
aria-describedby={`${id}-keyboard-desc`}
|
|
21
|
+
aria-multiselectable={multiselectable || undefined}
|
|
22
|
+
className='im-c-spatial-list'
|
|
23
|
+
onFocus={onFocus}
|
|
24
|
+
onBlur={onBlur}
|
|
25
|
+
>
|
|
26
|
+
{items.map(item => (
|
|
27
|
+
<li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern.
|
|
28
|
+
// tabIndex/onClick let voice control and screen readers reach and activate each option
|
|
29
|
+
// directly. focusable: false (draw's vertex/midpoint items) keeps every item out of
|
|
30
|
+
// the Tab order — that population already has a full keyboard path via the map itself.
|
|
31
|
+
key={item.id} id={`${id}-spatial-list-item-${item.id}`} role='option' // NOSONAR
|
|
32
|
+
data-id={item.id}
|
|
33
|
+
tabIndex={focusable && item.id === currentId ? 0 : -1}
|
|
34
|
+
aria-selected={selectedIds.includes(item.id)}
|
|
35
|
+
// Positions the (visually clipped) option over its on-map feature, so AT overlays like
|
|
36
|
+
// Voice Control's "Show Numbers" number it at the right screen position.
|
|
37
|
+
style={item.x != null ? { left: item.x, top: item.y } : undefined}
|
|
38
|
+
onClick={() => onSelectItem?.(item.id)}
|
|
39
|
+
>
|
|
40
|
+
{item.label}
|
|
41
|
+
</li>
|
|
42
|
+
))}
|
|
43
|
+
</ul>
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
SpatialList.displayName = 'SpatialList'
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
@use '../../../scss/tools/index' as tools;
|
|
2
2
|
|
|
3
3
|
// ===================================================
|
|
4
|
-
// Component:
|
|
4
|
+
// Component: SpatialList
|
|
5
5
|
// ===================================================
|
|
6
6
|
|
|
7
|
-
.im-c-
|
|
7
|
+
.im-c-spatial-list {
|
|
8
8
|
position: absolute;
|
|
9
9
|
inset: 0;
|
|
10
10
|
pointer-events: none;
|
|
@@ -16,10 +16,6 @@
|
|
|
16
16
|
$is-inset: true
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
-
&[data-focus-visible="true"][aria-activedescendant]::after {
|
|
20
|
-
opacity: 0;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
19
|
[role="option"] {
|
|
24
20
|
position: absolute;
|
|
25
21
|
width: 1px;
|
|
@@ -27,5 +23,9 @@
|
|
|
27
23
|
overflow: hidden;
|
|
28
24
|
clip-path: inset(50%);
|
|
29
25
|
white-space: nowrap;
|
|
26
|
+
// Roving tabindex moves real focus (and click activation) onto individual options, so they
|
|
27
|
+
// need pointer events re-enabled — overridden off .im-c-spatial-list's pointer-events: none,
|
|
28
|
+
// which otherwise lets mouse interaction pass through to the map everywhere else.
|
|
29
|
+
pointer-events: auto;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import React, { useRef } from 'react'
|
|
2
|
+
import { render, fireEvent, act } from '@testing-library/react'
|
|
3
|
+
import { SpatialList } from './SpatialList.jsx'
|
|
4
|
+
import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
|
|
5
|
+
import { useConfig } from '../../store/configContext.js'
|
|
6
|
+
|
|
7
|
+
jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
|
|
8
|
+
|
|
9
|
+
const APP_ID = 'test-app'
|
|
10
|
+
const LISTBOX = '[role="listbox"]' // NOSONAR
|
|
11
|
+
const OPTION = '[role="option"]' // NOSONAR
|
|
12
|
+
const ARIA_SELECTED = 'aria-selected'
|
|
13
|
+
const ITEMS = [
|
|
14
|
+
{ id: 'f1', label: 'Feature One' },
|
|
15
|
+
{ id: 'f2', label: 'Feature Two' }
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
useConfig.mockReturnValue({ id: APP_ID })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// ─── SpatialList — rendering ─────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('SpatialList — rendering', () => {
|
|
25
|
+
it('renders a listbox with the correct id', () => {
|
|
26
|
+
const { container } = render(<SpatialList />)
|
|
27
|
+
expect(container.querySelector(`#${APP_ID}-spatial-list`)).toBeTruthy()
|
|
28
|
+
expect(container.querySelector(LISTBOX)).toBeTruthy() // NOSONAR
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders no options when items is empty', () => {
|
|
32
|
+
const { container } = render(<SpatialList />)
|
|
33
|
+
expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('defaults aria-label to "Map features" when no label prop is given', () => {
|
|
37
|
+
const { container } = render(<SpatialList />)
|
|
38
|
+
expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Map features')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('uses whatever label prop is given, e.g. for draw\'s edit-mode vertices', () => {
|
|
42
|
+
const { container } = render(<SpatialList label='Shape points' />)
|
|
43
|
+
expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Shape points')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders one option per item with correct id, data-id and label', () => {
|
|
47
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
48
|
+
const options = container.querySelectorAll(OPTION)
|
|
49
|
+
expect(options).toHaveLength(2)
|
|
50
|
+
expect(options[0].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f1`)
|
|
51
|
+
expect(options[0].dataset.id).toBe('f1')
|
|
52
|
+
expect(options[0].textContent).toBe('Feature One')
|
|
53
|
+
expect(options[1].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f2`)
|
|
54
|
+
expect(options[1].dataset.id).toBe('f2')
|
|
55
|
+
expect(options[1].textContent).toBe('Feature Two')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('positions an option at its x/y when provided, for coordinate-based AT overlays (e.g. Voice Control)', () => {
|
|
59
|
+
const itemsWithPosition = [{ id: 'f1', label: 'Feature One', x: 120, y: 340 }]
|
|
60
|
+
const { container } = render(<SpatialList items={itemsWithPosition} />)
|
|
61
|
+
const option = container.querySelector(OPTION)
|
|
62
|
+
expect(option.style.left).toBe('120px')
|
|
63
|
+
expect(option.style.top).toBe('340px')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not set left/top when an item has no x/y', () => {
|
|
67
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
68
|
+
const option = container.querySelector(OPTION)
|
|
69
|
+
expect(option.style.left).toBe('')
|
|
70
|
+
expect(option.style.top).toBe('')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('sets aria-selected on items present in selectedIds', () => {
|
|
74
|
+
const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1']} />)
|
|
75
|
+
const options = container.querySelectorAll(OPTION)
|
|
76
|
+
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
77
|
+
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('sets aria-selected on multiple items when selectedIds has multiple entries', () => {
|
|
81
|
+
const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1', 'f2']} />)
|
|
82
|
+
const options = container.querySelectorAll(OPTION)
|
|
83
|
+
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
84
|
+
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not set aria-selected from activeItemId alone', () => {
|
|
88
|
+
const { container } = render(<SpatialList items={ITEMS} activeItemId='f1' />)
|
|
89
|
+
const options = container.querySelectorAll(OPTION)
|
|
90
|
+
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
91
|
+
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('gives the active item tabIndex 0 and every other item tabIndex -1 (roving tabindex)', () => {
|
|
95
|
+
const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' />)
|
|
96
|
+
const options = container.querySelectorAll(OPTION)
|
|
97
|
+
expect(options[0].getAttribute('tabIndex')).toBe('-1')
|
|
98
|
+
expect(options[1].getAttribute('tabIndex')).toBe('0')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('falls back to tabbableId for roving tabindex when activeItemId is absent', () => {
|
|
102
|
+
const { container } = render(<SpatialList items={ITEMS} tabbableId='f1' />)
|
|
103
|
+
const options = container.querySelectorAll(OPTION)
|
|
104
|
+
expect(options[0].getAttribute('tabIndex')).toBe('0')
|
|
105
|
+
expect(options[1].getAttribute('tabIndex')).toBe('-1')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('gives every item tabIndex -1 when focusable is false, even the active one — never a real Tab stop', () => {
|
|
109
|
+
const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' focusable={false} />)
|
|
110
|
+
const options = container.querySelectorAll(OPTION)
|
|
111
|
+
expect(options[0].getAttribute('tabIndex')).toBe('-1')
|
|
112
|
+
expect(options[1].getAttribute('tabIndex')).toBe('-1')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('sets aria-multiselectable when multiselectable is true', () => {
|
|
116
|
+
const { container } = render(<SpatialList items={ITEMS} multiselectable />)
|
|
117
|
+
expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBe('true') // NOSONAR
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('omits aria-multiselectable when multiselectable is false', () => {
|
|
121
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
122
|
+
expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBeNull() // NOSONAR
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('is not aria-hidden when items are present', () => {
|
|
126
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
127
|
+
const ul = container.querySelector(LISTBOX) // NOSONAR
|
|
128
|
+
expect(ul.getAttribute('aria-hidden')).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('is aria-hidden and has no options when items is empty', () => {
|
|
132
|
+
const { container } = render(<SpatialList />)
|
|
133
|
+
const ul = container.querySelector(LISTBOX) // NOSONAR
|
|
134
|
+
expect(ul.getAttribute('aria-hidden')).toBe('true')
|
|
135
|
+
expect(container.querySelectorAll(OPTION)).toHaveLength(0) // NOSONAR
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('sets aria-describedby to the shared hints container id', () => {
|
|
139
|
+
const { container } = render(<SpatialList />)
|
|
140
|
+
expect(container.querySelector(LISTBOX).getAttribute('aria-describedby')).toBe(`${APP_ID}-keyboard-desc`) // NOSONAR
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ─── SpatialList — interactions ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('SpatialList — interactions', () => {
|
|
147
|
+
it('calls onFocus when the listbox receives focus', () => {
|
|
148
|
+
const onFocus = jest.fn()
|
|
149
|
+
const { container } = render(<SpatialList onFocus={onFocus} />)
|
|
150
|
+
fireEvent.focus(container.querySelector(LISTBOX)) // NOSONAR
|
|
151
|
+
expect(onFocus).toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('calls onBlur when the listbox loses focus', () => {
|
|
155
|
+
const onBlur = jest.fn()
|
|
156
|
+
const { container } = render(<SpatialList onBlur={onBlur} />)
|
|
157
|
+
fireEvent.blur(container.querySelector(LISTBOX)) // NOSONAR
|
|
158
|
+
expect(onBlur).toHaveBeenCalled()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('calls onSelectItem with the clicked item id', () => {
|
|
162
|
+
const onSelectItem = jest.fn()
|
|
163
|
+
const { container } = render(<SpatialList items={ITEMS} onSelectItem={onSelectItem} />)
|
|
164
|
+
fireEvent.click(container.querySelectorAll(OPTION)[1]) // NOSONAR
|
|
165
|
+
expect(onSelectItem).toHaveBeenCalledWith('f2')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not throw on click when onSelectItem is not provided', () => {
|
|
169
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
170
|
+
expect(() => fireEvent.click(container.querySelectorAll(OPTION)[0])).not.toThrow() // NOSONAR
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ─── SpatialList + useSpatialListFocus — roving tabindex wired together for real ────
|
|
175
|
+
//
|
|
176
|
+
// The tests above exercise SpatialList in isolation (mocked onFocus/onBlur), and
|
|
177
|
+
// useSpatialListFocus.test.js exercises the hook in isolation (onFocus/onBlur called
|
|
178
|
+
// directly, not via real DOM events). Neither can catch a bug where moving real focus
|
|
179
|
+
// between sibling options — which roving tabindex does on every arrow key — fires a
|
|
180
|
+
// genuine native focusin that bubbles up and re-triggers React's onFocus on the <ul>.
|
|
181
|
+
// Only rendering both together with real (unmocked) focus exercises that bubbling path.
|
|
182
|
+
|
|
183
|
+
const RovingHarness = ({ eventBus }) => {
|
|
184
|
+
const viewportRef = useRef(document.createElement('div'))
|
|
185
|
+
const spatialListRef = useRef(null)
|
|
186
|
+
const { activeItemId, tabbableId, selectedIds, onFocus, onBlur, selectItem } = useSpatialListFocus({
|
|
187
|
+
viewportRef, spatialListRef, items: ITEMS, eventBus, hints: { subscribe: () => () => {}, dismiss: () => {} }
|
|
188
|
+
})
|
|
189
|
+
return (
|
|
190
|
+
<SpatialList
|
|
191
|
+
ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds}
|
|
192
|
+
items={ITEMS} onFocus={onFocus} onBlur={onBlur} onSelectItem={selectItem}
|
|
193
|
+
/>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
describe('SpatialList + useSpatialListFocus — roving tabindex real focus events', () => {
|
|
198
|
+
it('emits map:setactivefeature exactly once when arrowing to the next option', () => {
|
|
199
|
+
const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
|
|
200
|
+
const { container } = render(<RovingHarness eventBus={eb} />)
|
|
201
|
+
act(() => container.querySelector('[data-id="f1"]').focus())
|
|
202
|
+
eb.emit.mockClear()
|
|
203
|
+
fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
|
|
204
|
+
expect(eb.emit).toHaveBeenCalledTimes(1)
|
|
205
|
+
expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: 'f2' })
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('moves real DOM focus to the next option on ArrowDown', () => {
|
|
209
|
+
const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
|
|
210
|
+
const { container } = render(<RovingHarness eventBus={eb} />)
|
|
211
|
+
act(() => container.querySelector('[data-id="f1"]').focus())
|
|
212
|
+
fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
|
|
213
|
+
expect(document.activeElement.dataset.id).toBe('f2')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// A real pub/sub bus, with a minimal fake "interact plugin" listening on it — mirroring what
|
|
217
|
+
// useSpatialList.js actually does: track the last map:setactivefeature id, and on
|
|
218
|
+
// map:selectfeature, mark THAT id selected and fire interact:selectionchange back. This is
|
|
219
|
+
// the closest reproduction of the real reported bug: Enter only ever selecting the first item
|
|
220
|
+
// because every arrow move was clobbering the active id back to a stale value.
|
|
221
|
+
const makeFakeInteractBus = () => {
|
|
222
|
+
const listeners = {}
|
|
223
|
+
let lastActiveId = null
|
|
224
|
+
const bus = {
|
|
225
|
+
on: (event, fn) => { (listeners[event] ??= []).push(fn) },
|
|
226
|
+
off: (event, fn) => { listeners[event] = (listeners[event] ?? []).filter(f => f !== fn) },
|
|
227
|
+
emit: (event, payload) => { (listeners[event] ?? []).forEach(fn => fn(payload)) }
|
|
228
|
+
}
|
|
229
|
+
bus.on('map:setactiveitem', ({ id }) => { lastActiveId = id })
|
|
230
|
+
bus.on('map:selectitem', () => {
|
|
231
|
+
bus.emit('interact:selectionchange', { selectedMarkers: lastActiveId ? [lastActiveId] : [] })
|
|
232
|
+
})
|
|
233
|
+
return bus
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
it('selects the item that is actually focused, not the first item, after navigating and pressing Enter', () => {
|
|
237
|
+
const eb = makeFakeInteractBus()
|
|
238
|
+
const { container } = render(<RovingHarness eventBus={eb} />)
|
|
239
|
+
act(() => container.querySelector('[data-id="f1"]').focus())
|
|
240
|
+
const listbox = container.querySelector(LISTBOX)
|
|
241
|
+
fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // NOSONAR — moves to f2
|
|
242
|
+
fireEvent.keyDown(listbox, { key: 'Enter' })
|
|
243
|
+
const options = container.querySelectorAll(OPTION)
|
|
244
|
+
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false') // f1 — must not be the one selected
|
|
245
|
+
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') // f2 — the one actually focused
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('keeps navigating correctly after a selection (does not get stuck reverting to the first item)', () => {
|
|
249
|
+
const eb = makeFakeInteractBus()
|
|
250
|
+
const { container } = render(<RovingHarness eventBus={eb} />)
|
|
251
|
+
act(() => container.querySelector('[data-id="f1"]').focus())
|
|
252
|
+
const listbox = container.querySelector(LISTBOX)
|
|
253
|
+
fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // NOSONAR — f1 -> f2
|
|
254
|
+
fireEvent.keyDown(listbox, { key: 'Enter' }) // select f2
|
|
255
|
+
fireEvent.keyDown(listbox, { key: 'ArrowUp' }) // f2 -> f1
|
|
256
|
+
expect(document.activeElement.dataset.id).toBe('f1')
|
|
257
|
+
expect(container.querySelectorAll(OPTION)[0].tabIndex).toBe(0)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('selecting a marker on the map does not move the roving tabindex position — only aria-selected changes', () => {
|
|
261
|
+
const eb = makeFakeInteractBus()
|
|
262
|
+
const { container } = render(<RovingHarness eventBus={eb} />)
|
|
263
|
+
// Simulates repeatedly clicking a single marker on the map — never touches the list at all.
|
|
264
|
+
act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f1'] }) })
|
|
265
|
+
act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f2'] }) })
|
|
266
|
+
const options = container.querySelectorAll(OPTION)
|
|
267
|
+
expect(options[0].tabIndex).toBe(0) // f1 — the structural first item, unmoved
|
|
268
|
+
expect(options[1].tabIndex).toBe(-1)
|
|
269
|
+
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
270
|
+
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') // only this changed
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('moves real focus to match the selected item when Tab lands somewhere else', () => {
|
|
274
|
+
const eb = makeFakeInteractBus()
|
|
275
|
+
const { container } = render(<RovingHarness eventBus={eb} />)
|
|
276
|
+
// f2 selected via a map click before any keyboard interaction — tabbableId stays on the
|
|
277
|
+
// structural first item (f1), so Tab lands there first.
|
|
278
|
+
act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f2'] }) })
|
|
279
|
+
expect(container.querySelectorAll(OPTION)[0].tabIndex).toBe(0) // Tab will land on f1
|
|
280
|
+
act(() => container.querySelector('[data-id="f1"]').focus()) // simulates the Tab press
|
|
281
|
+
// onFocus resolves to the selected item (f2) and must move real focus there to match —
|
|
282
|
+
// otherwise the list's own focus/announcement would disagree with what the map highlights.
|
|
283
|
+
expect(document.activeElement.dataset.id).toBe('f2')
|
|
284
|
+
})
|
|
285
|
+
})
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useRef, useEffect } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
|
|
3
|
+
import { useSpatialListItems } from '../../hooks/useSpatialListItems.js'
|
|
4
4
|
import { EVENTS as events } from '../../../config/events.js'
|
|
5
|
+
import { scaleFactor } from '../../../config/appConfig.js'
|
|
5
6
|
import { useConfig } from '../../store/configContext.js'
|
|
6
7
|
import { useApp } from '../../store/appContext.js'
|
|
7
8
|
import { useMap } from '../../store/mapContext.js'
|
|
@@ -12,7 +13,7 @@ import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts.js'
|
|
|
12
13
|
import { useMapEvents } from '../../hooks/useMapEvents.js'
|
|
13
14
|
import { MapStatus } from './MapStatus.jsx'
|
|
14
15
|
import { CrossHair } from '../CrossHair/CrossHair'
|
|
15
|
-
import {
|
|
16
|
+
import { SpatialList } from './SpatialList'
|
|
16
17
|
import { Markers } from '../Markers/Markers'
|
|
17
18
|
|
|
18
19
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
@@ -20,14 +21,23 @@ import { Markers } from '../Markers/Markers'
|
|
|
20
21
|
export const Viewport = () => {
|
|
21
22
|
const { id, mapProvider, mapLabel, keyboardHintText, focusOnMount } = useConfig()
|
|
22
23
|
const { mode, previousMode, layoutRefs, safeZoneInset, dispatch } = useApp()
|
|
23
|
-
const { mapSize } = useMap()
|
|
24
|
+
const { mapSize, isMapReady } = useMap()
|
|
24
25
|
const { eventBus, hints } = useService()
|
|
25
26
|
|
|
26
27
|
const mapContainerRef = useRef(null)
|
|
27
|
-
const
|
|
28
|
+
const spatialListRef = useRef(null)
|
|
28
29
|
|
|
29
|
-
const { items:
|
|
30
|
-
|
|
30
|
+
const { items: spatialListItems, multiselectable, label: spatialListLabel, focusable: spatialListFocusable } = useSpatialListItems(eventBus)
|
|
31
|
+
// Same reference point as Alt+Enter's highlightLabelAtCenter — lets the listbox's entry
|
|
32
|
+
// fallback prefer whichever item is nearest the middle of the view. Guarded on isMapReady:
|
|
33
|
+
// the map engine doesn't exist yet on Viewport's first render, so mapToScreen()/getCenter()
|
|
34
|
+
// would throw; null is a safe, already-handled value (falls back to the first item).
|
|
35
|
+
let centerScreenPoint = null
|
|
36
|
+
if (isMapReady) {
|
|
37
|
+
const centerPixel = mapProvider.mapToScreen(mapProvider.getCenter())
|
|
38
|
+
centerScreenPoint = { x: centerPixel.x * scaleFactor[mapSize], y: centerPixel.y * scaleFactor[mapSize] }
|
|
39
|
+
}
|
|
40
|
+
const { activeItemId, tabbableId, selectedIds, onFocus: handleSpatialListFocus, onBlur: handleSpatialListBlur, selectItem } = useSpatialListFocus({ viewportRef: layoutRefs.viewportRef, spatialListRef, items: spatialListItems, eventBus, hints, centerScreenPoint })
|
|
31
41
|
|
|
32
42
|
useEffect(() => {
|
|
33
43
|
const handler = () => dispatch({ type: 'SET_LISTBOX_ACTIVE' })
|
|
@@ -35,8 +45,8 @@ export const Viewport = () => {
|
|
|
35
45
|
return () => eventBus.off('interact:listboxcapable', handler)
|
|
36
46
|
}, [eventBus])
|
|
37
47
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
48
|
+
const onSpatialListFocus = () => { handleSpatialListFocus(); hints.show(keyboardHintText, { duration: 0 }) }
|
|
49
|
+
const onSpatialListBlur = () => { handleSpatialListBlur(); hints.dismiss() }
|
|
40
50
|
|
|
41
51
|
useKeyboardShortcuts(layoutRefs.viewportRef)
|
|
42
52
|
|
|
@@ -87,14 +97,14 @@ export const Viewport = () => {
|
|
|
87
97
|
>
|
|
88
98
|
<div className='im-c-viewport__map-container' ref={mapContainerRef} aria-hidden='true' />
|
|
89
99
|
<MapStatus />
|
|
90
|
-
<div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef}
|
|
100
|
+
<div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef}>
|
|
91
101
|
<CrossHair />
|
|
92
102
|
</div>
|
|
93
103
|
<div className='im-c-viewport__markers' aria-hidden='true'>
|
|
94
104
|
<Markers />
|
|
95
105
|
</div>
|
|
96
106
|
</div>
|
|
97
|
-
<
|
|
107
|
+
<SpatialList ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds} multiselectable={multiselectable} items={spatialListItems} label={spatialListLabel} focusable={spatialListFocusable} onFocus={onSpatialListFocus} onBlur={onSpatialListBlur} onSelectItem={selectItem} />
|
|
98
108
|
</>
|
|
99
109
|
)
|
|
100
110
|
}
|
|
@@ -21,7 +21,13 @@ jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid
|
|
|
21
21
|
|
|
22
22
|
const KEYBOARD_HINT_TEXT = 'Test keyboad hint text'
|
|
23
23
|
|
|
24
|
-
const mockMapProvider = {
|
|
24
|
+
const mockMapProvider = {
|
|
25
|
+
initMap: jest.fn(),
|
|
26
|
+
updateMap: jest.fn(),
|
|
27
|
+
clearHighlightedLabel: jest.fn(),
|
|
28
|
+
getCenter: jest.fn(() => [0, 0]),
|
|
29
|
+
mapToScreen: jest.fn(() => ({ x: 100, y: 100 }))
|
|
30
|
+
}
|
|
25
31
|
|
|
26
32
|
function setupHookMocks (mainEl, viewportEl) {
|
|
27
33
|
useConfig.mockReturnValue({
|
|
@@ -38,7 +44,7 @@ function setupHookMocks (mainEl, viewportEl) {
|
|
|
38
44
|
safeZoneInset: {},
|
|
39
45
|
dispatch: jest.fn()
|
|
40
46
|
})
|
|
41
|
-
useMap.mockReturnValue({ mapSize: 'medium', dispatch: jest.fn() })
|
|
47
|
+
useMap.mockReturnValue({ mapSize: 'medium', isMapReady: true, dispatch: jest.fn() })
|
|
42
48
|
useService.mockReturnValue({
|
|
43
49
|
announce: jest.fn(),
|
|
44
50
|
hints: { show: jest.fn(), dismiss: jest.fn(), subscribe: jest.fn(() => jest.fn()) },
|
|
@@ -89,6 +95,16 @@ describe('Viewport rendering', () => {
|
|
|
89
95
|
expect(markers).toBeInTheDocument()
|
|
90
96
|
})
|
|
91
97
|
|
|
98
|
+
// Regression: Viewport (and MapController, which kicks off map creation) render before the
|
|
99
|
+
// underlying map engine actually exists — mapProvider.getCenter()/mapToScreen() throw until
|
|
100
|
+
// then, so the spatial listbox's "nearest to center" entry point must not call them yet.
|
|
101
|
+
it('does not throw, and skips mapProvider.getCenter/mapToScreen, before the map is ready', () => {
|
|
102
|
+
useMap.mockReturnValue({ mapSize: 'medium', isMapReady: false, dispatch: jest.fn() })
|
|
103
|
+
expect(() => renderViewport()).not.toThrow()
|
|
104
|
+
expect(mockMapProvider.getCenter).not.toHaveBeenCalled()
|
|
105
|
+
expect(mockMapProvider.mapToScreen).not.toHaveBeenCalled()
|
|
106
|
+
})
|
|
107
|
+
|
|
92
108
|
it('renders viewport with correct id and class based on mapSize', () => {
|
|
93
109
|
const { viewport } = renderViewport()
|
|
94
110
|
expect(viewport.id).toBe('test-map-viewport')
|