@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
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { useSpatialListFocus } from './useSpatialListFocus.js'
|
|
3
|
+
|
|
4
|
+
const ITEMS = [
|
|
5
|
+
{ id: 'a', label: 'Feature A' },
|
|
6
|
+
{ id: 'b', label: 'Feature B' },
|
|
7
|
+
{ id: 'c', label: 'Feature C' }
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
const SET_ACTIVE = 'map:setactiveitem' // NOSONAR
|
|
11
|
+
const SELECT = 'map:selectitem'
|
|
12
|
+
const SELECTION_CHANGE = 'interact:selectionchange'
|
|
13
|
+
|
|
14
|
+
const makeEventBus = () => {
|
|
15
|
+
const listeners = {}
|
|
16
|
+
return {
|
|
17
|
+
on: jest.fn((eventName, fn) => { listeners[eventName] = fn }),
|
|
18
|
+
off: jest.fn(),
|
|
19
|
+
emit: jest.fn(),
|
|
20
|
+
trigger: (eventName, payload) => listeners[eventName]?.(payload)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const makeRefs = ({ viewportFocus } = {}) => {
|
|
25
|
+
const viewportEl = document.createElement('div')
|
|
26
|
+
viewportEl.focus = viewportFocus ?? jest.fn()
|
|
27
|
+
return {
|
|
28
|
+
viewportRef: { current: viewportEl },
|
|
29
|
+
spatialListRef: { current: document.createElement('ul') },
|
|
30
|
+
hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fireKey = (el, key) => {
|
|
35
|
+
let event
|
|
36
|
+
act(() => {
|
|
37
|
+
event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
|
38
|
+
el.dispatchEvent(event)
|
|
39
|
+
})
|
|
40
|
+
return event
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── useSpatialListFocus — initial state ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe('useSpatialListFocus — initial state', () => {
|
|
46
|
+
it('activeItemId starts as null', () => {
|
|
47
|
+
const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
|
|
48
|
+
expect(result.current.activeItemId).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('exposes onFocus function', () => {
|
|
52
|
+
const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
|
|
53
|
+
expect(typeof result.current.onFocus).toBe('function')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('exposes onBlur function', () => {
|
|
57
|
+
const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
|
|
58
|
+
expect(typeof result.current.onBlur).toBe('function')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ─── useSpatialListFocus — onFocus ────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
describe('useSpatialListFocus — onFocus', () => {
|
|
65
|
+
it('sets activeItemId to first item on first focus (no previous, no selected)', () => {
|
|
66
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
|
|
67
|
+
act(() => result.current.onFocus())
|
|
68
|
+
expect(result.current.activeItemId).toBe('a')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('does nothing when items is empty', () => {
|
|
72
|
+
const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
|
|
73
|
+
act(() => result.current.onFocus())
|
|
74
|
+
expect(result.current.activeItemId).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('sets active to first selected item (highest priority)', () => {
|
|
78
|
+
const eb = makeEventBus()
|
|
79
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
80
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
|
|
81
|
+
act(() => result.current.onFocus())
|
|
82
|
+
expect(result.current.activeItemId).toBe('b')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('selected item takes priority over last active position', () => {
|
|
86
|
+
const eb = makeEventBus()
|
|
87
|
+
const refs = makeRefs()
|
|
88
|
+
const el = refs.spatialListRef.current
|
|
89
|
+
document.body.appendChild(el)
|
|
90
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS, eventBus: eb }))
|
|
91
|
+
act(() => result.current.onFocus())
|
|
92
|
+
fireKey(el, 'ArrowDown') // last active = 'b'
|
|
93
|
+
act(() => result.current.onBlur())
|
|
94
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'c', layerId: 'roads' }] }))
|
|
95
|
+
act(() => result.current.onFocus())
|
|
96
|
+
expect(result.current.activeItemId).toBe('c') // selected beats last-active
|
|
97
|
+
unmount(); el.remove()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('restores last active position when nothing is selected', () => {
|
|
101
|
+
const refs = makeRefs()
|
|
102
|
+
const el = refs.spatialListRef.current
|
|
103
|
+
document.body.appendChild(el)
|
|
104
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
105
|
+
act(() => result.current.onFocus())
|
|
106
|
+
fireKey(el, 'ArrowDown') // last active = 'b'
|
|
107
|
+
act(() => result.current.onBlur())
|
|
108
|
+
act(() => result.current.onFocus())
|
|
109
|
+
expect(result.current.activeItemId).toBe('b')
|
|
110
|
+
unmount(); el.remove()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('falls back to first item when previous active is no longer in the list', () => {
|
|
114
|
+
const eb = makeEventBus()
|
|
115
|
+
const refs = makeRefs()
|
|
116
|
+
const el = refs.spatialListRef.current
|
|
117
|
+
document.body.appendChild(el)
|
|
118
|
+
const { result, rerender, unmount } = renderHook(
|
|
119
|
+
({ items }) => useSpatialListFocus({ ...refs, items, eventBus: eb }),
|
|
120
|
+
{ initialProps: { items: ITEMS } }
|
|
121
|
+
)
|
|
122
|
+
act(() => result.current.onFocus())
|
|
123
|
+
fireKey(el, 'ArrowDown') // active = 'b'
|
|
124
|
+
act(() => result.current.onBlur())
|
|
125
|
+
const NEW_ITEMS = [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }]
|
|
126
|
+
rerender({ items: NEW_ITEMS })
|
|
127
|
+
act(() => result.current.onFocus())
|
|
128
|
+
expect(result.current.activeItemId).toBe('x')
|
|
129
|
+
unmount(); el.remove()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('prefers the item nearest the map center over the structurally-first one', () => {
|
|
133
|
+
const items = [
|
|
134
|
+
{ id: 'a', label: 'A', x: 0, y: 0 },
|
|
135
|
+
{ id: 'b', label: 'B', x: 100, y: 100 },
|
|
136
|
+
{ id: 'c', label: 'C', x: 500, y: 500 }
|
|
137
|
+
]
|
|
138
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items, centerScreenPoint: { x: 90, y: 90 } }))
|
|
139
|
+
act(() => result.current.onFocus())
|
|
140
|
+
expect(result.current.activeItemId).toBe('b')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('falls back to the first item when no centerScreenPoint is given, even with positioned items', () => {
|
|
144
|
+
const items = [
|
|
145
|
+
{ id: 'a', label: 'A', x: 0, y: 0 },
|
|
146
|
+
{ id: 'b', label: 'B', x: 100, y: 100 }
|
|
147
|
+
]
|
|
148
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items }))
|
|
149
|
+
act(() => result.current.onFocus())
|
|
150
|
+
expect(result.current.activeItemId).toBe('a')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('falls back to the first item when centerScreenPoint is given but no item carries a screen position', () => {
|
|
154
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, centerScreenPoint: { x: 90, y: 90 } }))
|
|
155
|
+
act(() => result.current.onFocus())
|
|
156
|
+
expect(result.current.activeItemId).toBe('a')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ─── useSpatialListFocus — onBlur ─────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe('useSpatialListFocus — onBlur', () => {
|
|
163
|
+
it('clears activeItemId when focus leaves the listbox', () => {
|
|
164
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
|
|
165
|
+
act(() => result.current.onFocus())
|
|
166
|
+
expect(result.current.activeItemId).toBe('a')
|
|
167
|
+
act(() => result.current.onBlur())
|
|
168
|
+
expect(result.current.activeItemId).toBeNull()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('emits map:setactivefeature with null on blur', () => {
|
|
172
|
+
const eb = makeEventBus()
|
|
173
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
174
|
+
act(() => result.current.onFocus())
|
|
175
|
+
eb.emit.mockClear()
|
|
176
|
+
act(() => result.current.onBlur())
|
|
177
|
+
expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: null })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('does not throw when eventBus is not provided', () => {
|
|
181
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
|
|
182
|
+
expect(() => act(() => result.current.onBlur())).not.toThrow()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// The "moving between sibling options shouldn't count as a real blur/focus" behavior is
|
|
186
|
+
// driven by an internal isInternalFocusMoveRef, set only while focusOption()'s own .focus()
|
|
187
|
+
// call is synchronously in flight — not observable by calling onFocus/onBlur directly here,
|
|
188
|
+
// since that requires a real DOM focus transition bubbling through React. See the
|
|
189
|
+
// "SpatialList + useSpatialListFocus — roving tabindex real focus events" suite in SpatialList.test.jsx,
|
|
190
|
+
// which renders the real markup and is the only level this can be faithfully exercised at
|
|
191
|
+
// (and is where the regression this guards against was actually found).
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ─── useSpatialListFocus — keydown listener lifecycle ────────────────────────────
|
|
195
|
+
|
|
196
|
+
describe('useSpatialListFocus — keydown listener lifecycle', () => {
|
|
197
|
+
it('does not attach listener when spatialListRef.current is null', () => {
|
|
198
|
+
const refs = makeRefs()
|
|
199
|
+
refs.spatialListRef = { current: null }
|
|
200
|
+
const spy = jest.spyOn(document.body, 'addEventListener')
|
|
201
|
+
renderHook(() => useSpatialListFocus(refs))
|
|
202
|
+
expect(spy).not.toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
203
|
+
spy.mockRestore()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('attaches and removes keydown listener on the features element', () => {
|
|
207
|
+
const refs = makeRefs()
|
|
208
|
+
const el = refs.spatialListRef.current
|
|
209
|
+
const addSpy = jest.spyOn(el, 'addEventListener')
|
|
210
|
+
const removeSpy = jest.spyOn(el, 'removeEventListener')
|
|
211
|
+
const { unmount } = renderHook(() => useSpatialListFocus(refs))
|
|
212
|
+
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
213
|
+
unmount()
|
|
214
|
+
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ─── useSpatialListFocus — unhandled keys ────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
describe('useSpatialListFocus — unhandled keys', () => {
|
|
221
|
+
it('does nothing for keys that are not Escape or Arrow', () => {
|
|
222
|
+
const viewportFocus = jest.fn()
|
|
223
|
+
const refs = makeRefs({ viewportFocus })
|
|
224
|
+
const el = refs.spatialListRef.current
|
|
225
|
+
document.body.appendChild(el)
|
|
226
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
227
|
+
fireKey(el, 'Tab')
|
|
228
|
+
expect(result.current.activeItemId).toBeNull()
|
|
229
|
+
expect(viewportFocus).not.toHaveBeenCalled()
|
|
230
|
+
el.remove()
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ─── useSpatialListFocus — Escape key ────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe('useSpatialListFocus — Escape key', () => {
|
|
237
|
+
it('dismisses hint on Escape when hint is visible', () => {
|
|
238
|
+
const refs = makeRefs()
|
|
239
|
+
refs.hints.subscribe.mockImplementation((fn) => {
|
|
240
|
+
fn({ html: 'test' })
|
|
241
|
+
return jest.fn()
|
|
242
|
+
})
|
|
243
|
+
const el = refs.spatialListRef.current
|
|
244
|
+
document.body.appendChild(el)
|
|
245
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
246
|
+
act(() => result.current.onFocus())
|
|
247
|
+
fireKey(el, 'Escape')
|
|
248
|
+
expect(refs.hints.dismiss).toHaveBeenCalled()
|
|
249
|
+
expect(refs.viewportRef.current.focus).not.toHaveBeenCalled()
|
|
250
|
+
el.remove()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('focuses the viewport on Escape when no hint is visible', () => {
|
|
254
|
+
const viewportFocus = jest.fn()
|
|
255
|
+
const refs = makeRefs({ viewportFocus })
|
|
256
|
+
const el = refs.spatialListRef.current
|
|
257
|
+
document.body.appendChild(el)
|
|
258
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
259
|
+
act(() => result.current.onFocus())
|
|
260
|
+
fireKey(el, 'Escape')
|
|
261
|
+
expect(refs.hints.dismiss).not.toHaveBeenCalled()
|
|
262
|
+
expect(viewportFocus).toHaveBeenCalled()
|
|
263
|
+
el.remove()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('does not throw when viewportRef.current is null', () => {
|
|
267
|
+
const refs = { viewportRef: { current: null }, spatialListRef: { current: document.createElement('ul') }, hints: { subscribe: jest.fn(() => jest.fn()), dismiss: jest.fn() } }
|
|
268
|
+
const el = refs.spatialListRef.current
|
|
269
|
+
document.body.appendChild(el)
|
|
270
|
+
renderHook(() => useSpatialListFocus(refs))
|
|
271
|
+
expect(() => fireKey(el, 'Escape')).not.toThrow()
|
|
272
|
+
el.remove()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('stops propagation on Escape so the viewport keyboard handler does not also handle it', () => {
|
|
276
|
+
const refs = makeRefs()
|
|
277
|
+
const el = refs.spatialListRef.current
|
|
278
|
+
document.body.appendChild(el)
|
|
279
|
+
renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
280
|
+
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
|
281
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
282
|
+
act(() => el.dispatchEvent(event))
|
|
283
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
284
|
+
el.remove()
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// ─── useSpatialListFocus — ArrowDown navigation ───────────────────────────────────
|
|
289
|
+
|
|
290
|
+
describe('useSpatialListFocus — ArrowDown navigation', () => {
|
|
291
|
+
const setup = () => {
|
|
292
|
+
const refs = makeRefs()
|
|
293
|
+
const el = refs.spatialListRef.current
|
|
294
|
+
document.body.appendChild(el)
|
|
295
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
296
|
+
return { result, el, unmount }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
it('selects first item when no item is active', () => {
|
|
300
|
+
const { result, el, unmount } = setup()
|
|
301
|
+
fireKey(el, 'ArrowDown')
|
|
302
|
+
expect(result.current.activeItemId).toBe('a')
|
|
303
|
+
unmount(); el.remove()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('advances to next item', () => {
|
|
307
|
+
const { result, el, unmount } = setup()
|
|
308
|
+
act(() => result.current.onFocus())
|
|
309
|
+
fireKey(el, 'ArrowDown')
|
|
310
|
+
expect(result.current.activeItemId).toBe('b')
|
|
311
|
+
unmount(); el.remove()
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('clamps at last item', () => {
|
|
315
|
+
const refs = makeRefs()
|
|
316
|
+
const el = refs.spatialListRef.current
|
|
317
|
+
document.body.appendChild(el)
|
|
318
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
319
|
+
act(() => result.current.onFocus())
|
|
320
|
+
fireKey(el, 'ArrowDown')
|
|
321
|
+
fireKey(el, 'ArrowDown')
|
|
322
|
+
fireKey(el, 'ArrowDown')
|
|
323
|
+
expect(result.current.activeItemId).toBe('c')
|
|
324
|
+
unmount(); el.remove()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('does nothing when items is empty', () => {
|
|
328
|
+
const refs = makeRefs()
|
|
329
|
+
const el = refs.spatialListRef.current
|
|
330
|
+
document.body.appendChild(el)
|
|
331
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus(refs))
|
|
332
|
+
fireKey(el, 'ArrowDown')
|
|
333
|
+
expect(result.current.activeItemId).toBeNull()
|
|
334
|
+
unmount(); el.remove()
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('stops propagation on ArrowDown so the viewport keyboard handler does not pan the map', () => {
|
|
338
|
+
const { result, el, unmount } = setup()
|
|
339
|
+
act(() => result.current.onFocus())
|
|
340
|
+
const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
|
|
341
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
342
|
+
act(() => el.dispatchEvent(event))
|
|
343
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
344
|
+
unmount(); el.remove()
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// ─── useSpatialListFocus — ArrowUp navigation ────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe('useSpatialListFocus — ArrowUp navigation', () => {
|
|
351
|
+
it('selects last item when no item is active', () => {
|
|
352
|
+
const refs = makeRefs()
|
|
353
|
+
const el = refs.spatialListRef.current
|
|
354
|
+
document.body.appendChild(el)
|
|
355
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
356
|
+
fireKey(el, 'ArrowUp')
|
|
357
|
+
expect(result.current.activeItemId).toBe('c')
|
|
358
|
+
unmount(); el.remove()
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('moves to previous item', () => {
|
|
362
|
+
const refs = makeRefs()
|
|
363
|
+
const el = refs.spatialListRef.current
|
|
364
|
+
document.body.appendChild(el)
|
|
365
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
366
|
+
act(() => result.current.onFocus())
|
|
367
|
+
fireKey(el, 'ArrowDown')
|
|
368
|
+
fireKey(el, 'ArrowUp')
|
|
369
|
+
expect(result.current.activeItemId).toBe('a')
|
|
370
|
+
unmount(); el.remove()
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('clamps at first item', () => {
|
|
374
|
+
const refs = makeRefs()
|
|
375
|
+
const el = refs.spatialListRef.current
|
|
376
|
+
document.body.appendChild(el)
|
|
377
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
378
|
+
act(() => result.current.onFocus())
|
|
379
|
+
fireKey(el, 'ArrowUp')
|
|
380
|
+
fireKey(el, 'ArrowUp')
|
|
381
|
+
expect(result.current.activeItemId).toBe('a')
|
|
382
|
+
unmount(); el.remove()
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// ─── useSpatialListFocus — Alt+Arrow spatial navigation ──────────────────────────
|
|
387
|
+
|
|
388
|
+
describe('useSpatialListFocus — Alt+Arrow spatial navigation', () => {
|
|
389
|
+
const POSITIONED_ITEMS = [
|
|
390
|
+
{ id: 'center', label: 'Center', x: 0, y: 0 },
|
|
391
|
+
{ id: 'up', label: 'Up', x: 0, y: -10 },
|
|
392
|
+
{ id: 'right', label: 'Right', x: 10, y: 0 }
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
const setup = (items = POSITIONED_ITEMS) => {
|
|
396
|
+
const refs = makeRefs()
|
|
397
|
+
const el = refs.spatialListRef.current
|
|
398
|
+
document.body.appendChild(el)
|
|
399
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items }))
|
|
400
|
+
return { result, el, unmount }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const fireAltKey = (el, key, type = 'keydown') => {
|
|
404
|
+
let event
|
|
405
|
+
act(() => {
|
|
406
|
+
event = new KeyboardEvent(type, { key, altKey: true, bubbles: true, cancelable: true })
|
|
407
|
+
el.dispatchEvent(event)
|
|
408
|
+
})
|
|
409
|
+
return event
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
it('moves to the item spatially nearest in the pressed direction, not the next item in list order', () => {
|
|
413
|
+
const { result, el, unmount } = setup()
|
|
414
|
+
act(() => result.current.onFocus()) // resolves to 'center', the first item
|
|
415
|
+
fireAltKey(el, 'ArrowRight')
|
|
416
|
+
expect(result.current.activeItemId).toBe('right')
|
|
417
|
+
unmount(); el.remove()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('emits map:setactivefeature for the spatially-found item', () => {
|
|
421
|
+
const refs = makeRefs()
|
|
422
|
+
const el = refs.spatialListRef.current
|
|
423
|
+
document.body.appendChild(el)
|
|
424
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: POSITIONED_ITEMS, eventBus: makeEventBus() }))
|
|
425
|
+
act(() => result.current.onFocus())
|
|
426
|
+
fireAltKey(el, 'ArrowUp')
|
|
427
|
+
expect(result.current.activeItemId).toBe('up')
|
|
428
|
+
unmount(); el.remove()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('prevents the browser default (e.g. Alt+Arrow history navigation) on keydown', () => {
|
|
432
|
+
const { result, el, unmount } = setup()
|
|
433
|
+
act(() => result.current.onFocus())
|
|
434
|
+
let event
|
|
435
|
+
act(() => {
|
|
436
|
+
event = new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true, cancelable: true })
|
|
437
|
+
})
|
|
438
|
+
const preventSpy = jest.spyOn(event, 'preventDefault')
|
|
439
|
+
act(() => el.dispatchEvent(event))
|
|
440
|
+
expect(preventSpy).toHaveBeenCalled()
|
|
441
|
+
unmount(); el.remove()
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('stops the matching keyup from bubbling to the viewport label-navigation binding on a shared ancestor', () => {
|
|
445
|
+
const { result, el, unmount } = setup()
|
|
446
|
+
act(() => result.current.onFocus())
|
|
447
|
+
let event
|
|
448
|
+
act(() => {
|
|
449
|
+
event = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: true, bubbles: true, cancelable: true })
|
|
450
|
+
})
|
|
451
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
452
|
+
act(() => el.dispatchEvent(event))
|
|
453
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
454
|
+
unmount(); el.remove()
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('leaves a plain (non-Alt) arrow keyup alone — sequential navigation has already handled it on keydown', () => {
|
|
458
|
+
const { result, el, unmount } = setup()
|
|
459
|
+
act(() => result.current.onFocus())
|
|
460
|
+
let event
|
|
461
|
+
act(() => {
|
|
462
|
+
event = new KeyboardEvent('keyup', { key: 'ArrowRight', altKey: false, bubbles: true, cancelable: true })
|
|
463
|
+
})
|
|
464
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
465
|
+
act(() => el.dispatchEvent(event))
|
|
466
|
+
expect(stopSpy).not.toHaveBeenCalled()
|
|
467
|
+
unmount(); el.remove()
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('stops an Alt+Enter keyup from bubbling to the viewport center-label binding on a shared ancestor', () => {
|
|
471
|
+
const { result, el, unmount } = setup()
|
|
472
|
+
act(() => result.current.onFocus())
|
|
473
|
+
let event
|
|
474
|
+
act(() => {
|
|
475
|
+
event = new KeyboardEvent('keyup', { key: 'Enter', altKey: true, bubbles: true, cancelable: true })
|
|
476
|
+
})
|
|
477
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
478
|
+
act(() => el.dispatchEvent(event))
|
|
479
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
480
|
+
unmount(); el.remove()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('leaves an Alt+<other> keyup alone — nothing else uses this modifier here', () => {
|
|
484
|
+
const { result, el, unmount } = setup()
|
|
485
|
+
act(() => result.current.onFocus())
|
|
486
|
+
let event
|
|
487
|
+
act(() => {
|
|
488
|
+
event = new KeyboardEvent('keyup', { key: 'i', altKey: true, bubbles: true, cancelable: true })
|
|
489
|
+
})
|
|
490
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
491
|
+
act(() => el.dispatchEvent(event))
|
|
492
|
+
expect(stopSpy).not.toHaveBeenCalled()
|
|
493
|
+
unmount(); el.remove()
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('is a no-op when no item lies in the pressed direction — stays on the current item', () => {
|
|
497
|
+
const { result, el, unmount } = setup([{ id: 'only', label: 'Only', x: 0, y: 0 }])
|
|
498
|
+
act(() => result.current.onFocus())
|
|
499
|
+
fireAltKey(el, 'ArrowRight')
|
|
500
|
+
expect(result.current.activeItemId).toBe('only')
|
|
501
|
+
unmount(); el.remove()
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
// ─── useSpatialListFocus — Home/End navigation ───────────────────────────────────
|
|
506
|
+
|
|
507
|
+
describe('useSpatialListFocus — Home/End navigation', () => {
|
|
508
|
+
const setup = () => {
|
|
509
|
+
const refs = makeRefs()
|
|
510
|
+
const el = refs.spatialListRef.current
|
|
511
|
+
document.body.appendChild(el)
|
|
512
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
513
|
+
return { result, el, unmount }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
it('jumps to the first item on Home from anywhere in the list', () => {
|
|
517
|
+
const { result, el, unmount } = setup()
|
|
518
|
+
act(() => result.current.onFocus())
|
|
519
|
+
fireKey(el, 'ArrowDown')
|
|
520
|
+
fireKey(el, 'Home')
|
|
521
|
+
expect(result.current.activeItemId).toBe('a')
|
|
522
|
+
unmount(); el.remove()
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('jumps to the last item on End from anywhere in the list', () => {
|
|
526
|
+
const { result, el, unmount } = setup()
|
|
527
|
+
act(() => result.current.onFocus())
|
|
528
|
+
fireKey(el, 'End')
|
|
529
|
+
expect(result.current.activeItemId).toBe('c')
|
|
530
|
+
unmount(); el.remove()
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('does nothing when items is empty', () => {
|
|
534
|
+
const refs = makeRefs()
|
|
535
|
+
const el = refs.spatialListRef.current
|
|
536
|
+
document.body.appendChild(el)
|
|
537
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus(refs))
|
|
538
|
+
fireKey(el, 'Home')
|
|
539
|
+
fireKey(el, 'End')
|
|
540
|
+
expect(result.current.activeItemId).toBeNull()
|
|
541
|
+
unmount(); el.remove()
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('stops propagation on Home/End so the viewport keyboard handler does not also handle it', () => {
|
|
545
|
+
const { el, unmount } = setup()
|
|
546
|
+
for (const key of ['Home', 'End']) {
|
|
547
|
+
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
|
548
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
549
|
+
act(() => el.dispatchEvent(event))
|
|
550
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
551
|
+
}
|
|
552
|
+
unmount()
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('emits map:setactivefeature with the boundary id', () => {
|
|
556
|
+
const eb = makeEventBus()
|
|
557
|
+
const refs = makeRefs()
|
|
558
|
+
const el = refs.spatialListRef.current
|
|
559
|
+
document.body.appendChild(el)
|
|
560
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS, eventBus: eb }))
|
|
561
|
+
act(() => result.current.onFocus())
|
|
562
|
+
eb.emit.mockClear()
|
|
563
|
+
fireKey(el, 'End')
|
|
564
|
+
expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'c' })
|
|
565
|
+
unmount(); el.remove()
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
// ─── useSpatialListFocus — eventBus integration ───────────────────────────────────
|
|
570
|
+
|
|
571
|
+
describe('useSpatialListFocus — eventBus: map:setactivefeature listener', () => {
|
|
572
|
+
it('subscribes to map:setactivefeature on mount and unsubscribes on unmount', () => {
|
|
573
|
+
const eb = makeEventBus()
|
|
574
|
+
const { unmount } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
|
|
575
|
+
expect(eb.on).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function))
|
|
576
|
+
unmount()
|
|
577
|
+
expect(eb.off).toHaveBeenCalledWith(SET_ACTIVE, expect.any(Function))
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('updates activeItemId when map:setactivefeature is received', () => {
|
|
581
|
+
const eb = makeEventBus()
|
|
582
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
|
|
583
|
+
act(() => eb.trigger(SET_ACTIVE, { id: 'a' }))
|
|
584
|
+
expect(result.current.activeItemId).toBe('a')
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('clears activeItemId when map:setactivefeature is received with null', () => {
|
|
588
|
+
const eb = makeEventBus()
|
|
589
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
590
|
+
act(() => eb.trigger(SET_ACTIVE, { id: 'a' }))
|
|
591
|
+
act(() => eb.trigger(SET_ACTIVE, { id: null }))
|
|
592
|
+
expect(result.current.activeItemId).toBeNull()
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
const setupWithBus = () => {
|
|
597
|
+
const eb = makeEventBus()
|
|
598
|
+
const refs = makeRefs()
|
|
599
|
+
const el = refs.spatialListRef.current
|
|
600
|
+
document.body.appendChild(el)
|
|
601
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS, eventBus: eb }))
|
|
602
|
+
return { eb, el, result, unmount }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
describe('useSpatialListFocus — eventBus: map:setactivefeature emit', () => {
|
|
606
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
607
|
+
|
|
608
|
+
it('emits map:setactivefeature with first item id on onFocus', () => {
|
|
609
|
+
const { eb, result } = setupWithBus()
|
|
610
|
+
act(() => result.current.onFocus())
|
|
611
|
+
expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'a' })
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('emits map:setactivefeature with new id on ArrowDown', () => {
|
|
615
|
+
const { eb, result, el, unmount } = setupWithBus()
|
|
616
|
+
act(() => result.current.onFocus())
|
|
617
|
+
eb.emit.mockClear()
|
|
618
|
+
fireKey(el, 'ArrowDown')
|
|
619
|
+
expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'b' })
|
|
620
|
+
unmount()
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('does not emit when eventBus is not provided', () => {
|
|
624
|
+
const refs = makeRefs()
|
|
625
|
+
const el = refs.spatialListRef.current
|
|
626
|
+
document.body.appendChild(el)
|
|
627
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
628
|
+
expect(() => {
|
|
629
|
+
act(() => result.current.onFocus())
|
|
630
|
+
fireKey(el, 'ArrowDown')
|
|
631
|
+
}).not.toThrow()
|
|
632
|
+
unmount()
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
// ─── useSpatialListFocus — interact:selectionchange listener ─────────────────────
|
|
637
|
+
|
|
638
|
+
describe('useSpatialListFocus — interact:selectionchange listener', () => {
|
|
639
|
+
it('subscribes to interact:selectionchange on mount and unsubscribes on unmount', () => {
|
|
640
|
+
const eb = makeEventBus()
|
|
641
|
+
const { unmount } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
|
|
642
|
+
expect(eb.on).toHaveBeenCalledWith(SELECTION_CHANGE, expect.any(Function))
|
|
643
|
+
unmount()
|
|
644
|
+
expect(eb.off).toHaveBeenCalledWith(SELECTION_CHANGE, expect.any(Function))
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('sets selectedIds from selected features', () => {
|
|
648
|
+
const eb = makeEventBus()
|
|
649
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
|
|
650
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 27665979, layerId: 'hedges' }] }))
|
|
651
|
+
expect(result.current.selectedIds).toEqual(['27665979'])
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('sets selectedIds from multiple selected features', () => {
|
|
655
|
+
const eb = makeEventBus()
|
|
656
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
|
|
657
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }, { featureId: 'f2', layerId: 'roads' }] }))
|
|
658
|
+
expect(result.current.selectedIds).toEqual(['f1', 'f2'])
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('sets selectedIds from selected markers', () => {
|
|
662
|
+
const eb = makeEventBus()
|
|
663
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
|
|
664
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['m1', 'm2'] }))
|
|
665
|
+
expect(result.current.selectedIds).toEqual(['m1', 'm2'])
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('sets selectedIds from both features and markers when both are selected', () => {
|
|
669
|
+
const eb = makeEventBus()
|
|
670
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
|
|
671
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }], selectedMarkers: ['m1'] }))
|
|
672
|
+
expect(result.current.selectedIds).toEqual(['f1', 'm1'])
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('clears selectedIds when selection becomes empty', () => {
|
|
676
|
+
const eb = makeEventBus()
|
|
677
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), eventBus: eb }))
|
|
678
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'f1', layerId: 'roads' }] }))
|
|
679
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [], selectedMarkers: [] }))
|
|
680
|
+
expect(result.current.selectedIds).toEqual([])
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('does not move activeItemId when selectionchange fires (cursor is keyboard-only)', () => {
|
|
684
|
+
const eb = makeEventBus()
|
|
685
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
686
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'a', layerId: 'roads' }] }))
|
|
687
|
+
expect(result.current.activeItemId).toBeNull()
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
// ─── useSpatialListFocus — map interaction returns focus to viewport ──────────────
|
|
692
|
+
|
|
693
|
+
describe('useSpatialListFocus — map pointerdown returns focus to viewport', () => {
|
|
694
|
+
const pointerDown = (target) => act(() => {
|
|
695
|
+
target.dispatchEvent(new Event('pointerdown', { bubbles: true, cancelable: true }))
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('focuses the viewport when pointer lands on the map while listbox is focused', () => {
|
|
699
|
+
const viewportFocus = jest.fn()
|
|
700
|
+
const refs = makeRefs({ viewportFocus })
|
|
701
|
+
document.body.appendChild(refs.viewportRef.current)
|
|
702
|
+
const mapCanvas = document.createElement('canvas')
|
|
703
|
+
refs.viewportRef.current.appendChild(mapCanvas)
|
|
704
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
705
|
+
act(() => result.current.onFocus())
|
|
706
|
+
viewportFocus.mockClear()
|
|
707
|
+
pointerDown(mapCanvas)
|
|
708
|
+
expect(viewportFocus).toHaveBeenCalled()
|
|
709
|
+
unmount()
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
it('does not move focus when pointer lands inside the listbox', () => {
|
|
713
|
+
const viewportFocus = jest.fn()
|
|
714
|
+
const refs = makeRefs({ viewportFocus })
|
|
715
|
+
document.body.appendChild(refs.viewportRef.current)
|
|
716
|
+
refs.viewportRef.current.appendChild(refs.spatialListRef.current)
|
|
717
|
+
const option = document.createElement('li')
|
|
718
|
+
refs.spatialListRef.current.appendChild(option)
|
|
719
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
720
|
+
act(() => result.current.onFocus())
|
|
721
|
+
viewportFocus.mockClear()
|
|
722
|
+
pointerDown(option)
|
|
723
|
+
expect(viewportFocus).not.toHaveBeenCalled()
|
|
724
|
+
unmount()
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('does not move focus when pointer fires while listbox is not focused', () => {
|
|
728
|
+
const viewportFocus = jest.fn()
|
|
729
|
+
const refs = makeRefs({ viewportFocus })
|
|
730
|
+
document.body.appendChild(refs.viewportRef.current)
|
|
731
|
+
const mapCanvas = document.createElement('canvas')
|
|
732
|
+
refs.viewportRef.current.appendChild(mapCanvas)
|
|
733
|
+
const { unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
734
|
+
pointerDown(mapCanvas)
|
|
735
|
+
expect(viewportFocus).not.toHaveBeenCalled()
|
|
736
|
+
unmount()
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
// ─── useSpatialListFocus — Enter and Space keys ───────────────────────────────────
|
|
743
|
+
|
|
744
|
+
describe('useSpatialListFocus — Enter and Space keys', () => {
|
|
745
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
746
|
+
|
|
747
|
+
it('emits map:selectfeature on Enter', () => {
|
|
748
|
+
const { eb, result, el, unmount } = setupWithBus()
|
|
749
|
+
act(() => result.current.onFocus())
|
|
750
|
+
eb.emit.mockClear()
|
|
751
|
+
fireKey(el, 'Enter')
|
|
752
|
+
expect(eb.emit).toHaveBeenCalledWith(SELECT)
|
|
753
|
+
unmount()
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it('emits map:selectfeature on Space', () => {
|
|
757
|
+
const { eb, result, el, unmount } = setupWithBus()
|
|
758
|
+
act(() => result.current.onFocus())
|
|
759
|
+
eb.emit.mockClear()
|
|
760
|
+
fireKey(el, ' ')
|
|
761
|
+
expect(eb.emit).toHaveBeenCalledWith(SELECT)
|
|
762
|
+
unmount()
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it('stops propagation on Enter', () => {
|
|
766
|
+
const { el, result, unmount } = setupWithBus()
|
|
767
|
+
act(() => result.current.onFocus())
|
|
768
|
+
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
|
|
769
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
770
|
+
act(() => el.dispatchEvent(event))
|
|
771
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
772
|
+
unmount()
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
it('stops propagation on Space', () => {
|
|
776
|
+
const { el, result, unmount } = setupWithBus()
|
|
777
|
+
act(() => result.current.onFocus())
|
|
778
|
+
const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
|
|
779
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
780
|
+
act(() => el.dispatchEvent(event))
|
|
781
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
782
|
+
unmount()
|
|
783
|
+
})
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
// ─── useSpatialListFocus — items change while focused ─────────────────────────────
|
|
787
|
+
|
|
788
|
+
describe('useSpatialListFocus — items change while focused', () => {
|
|
789
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
790
|
+
|
|
791
|
+
const setupRerender = (initialItems) => {
|
|
792
|
+
const eb = makeEventBus()
|
|
793
|
+
const refs = makeRefs()
|
|
794
|
+
document.body.appendChild(refs.spatialListRef.current)
|
|
795
|
+
const { result, rerender, unmount } = renderHook(
|
|
796
|
+
({ items }) => useSpatialListFocus({ ...refs, items, eventBus: eb }),
|
|
797
|
+
{ initialProps: { items: initialItems } }
|
|
798
|
+
)
|
|
799
|
+
return { eb, result, rerender, unmount }
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
it('keeps current active item when it is still in the updated list', () => {
|
|
803
|
+
const { result, rerender, unmount } = setupRerender(ITEMS)
|
|
804
|
+
act(() => result.current.onFocus()) // active = 'a'
|
|
805
|
+
act(() => { rerender({ items: [{ id: 'a', label: 'A updated' }, { id: 'd', label: 'D' }] }) })
|
|
806
|
+
expect(result.current.activeItemId).toBe('a')
|
|
807
|
+
unmount()
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it('re-picks by priority when current active item leaves the list — first selected wins', () => {
|
|
811
|
+
const { eb, result, rerender, unmount } = setupRerender(ITEMS)
|
|
812
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'c', layerId: 'l' }] }))
|
|
813
|
+
act(() => result.current.onFocus()) // active = 'a' (first, no prev)
|
|
814
|
+
act(() => { rerender({ items: [{ id: 'c', label: 'C' }, { id: 'd', label: 'D' }] }) }) // 'a' gone
|
|
815
|
+
expect(result.current.activeItemId).toBe('c') // first selected
|
|
816
|
+
unmount()
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
it('re-picks first item when active leaves and no selected item is in the new list', () => {
|
|
820
|
+
const { result, rerender, unmount } = setupRerender(ITEMS)
|
|
821
|
+
act(() => result.current.onFocus()) // active = 'a'
|
|
822
|
+
act(() => { rerender({ items: [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }] }) })
|
|
823
|
+
expect(result.current.activeItemId).toBe('x')
|
|
824
|
+
unmount()
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
it('does not change active item when the listbox is not focused', () => {
|
|
828
|
+
const { result, rerender, unmount } = setupRerender(ITEMS)
|
|
829
|
+
// never called onFocus — isFocusedRef stays false
|
|
830
|
+
act(() => { rerender({ items: [{ id: 'x', label: 'X' }] }) })
|
|
831
|
+
expect(result.current.activeItemId).toBeNull()
|
|
832
|
+
unmount()
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
it('emits map:setactivefeature with new id when re-pick occurs', () => {
|
|
836
|
+
const { eb, result, rerender, unmount } = setupRerender(ITEMS)
|
|
837
|
+
act(() => result.current.onFocus())
|
|
838
|
+
eb.emit.mockClear()
|
|
839
|
+
act(() => { rerender({ items: [{ id: 'x', label: 'X' }] }) }) // 'a' gone
|
|
840
|
+
expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: 'x' })
|
|
841
|
+
unmount()
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('clears active and emits null when items list becomes empty while focused', () => {
|
|
845
|
+
const { eb, result, rerender, unmount } = setupRerender(ITEMS)
|
|
846
|
+
act(() => result.current.onFocus())
|
|
847
|
+
eb.emit.mockClear()
|
|
848
|
+
act(() => { rerender({ items: [] }) })
|
|
849
|
+
expect(result.current.activeItemId).toBeNull()
|
|
850
|
+
expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: null })
|
|
851
|
+
unmount()
|
|
852
|
+
})
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
// ─── useSpatialListFocus — tabbableId (roving tabindex resting position) ─────────
|
|
856
|
+
|
|
857
|
+
describe('useSpatialListFocus — tabbableId', () => {
|
|
858
|
+
it('is null when items is empty', () => {
|
|
859
|
+
const { result } = renderHook(() => useSpatialListFocus(makeRefs()))
|
|
860
|
+
expect(result.current.tabbableId).toBeNull()
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it('defaults to the item nearest the map center before the list has ever been focused', () => {
|
|
864
|
+
const items = [
|
|
865
|
+
{ id: 'a', label: 'A', x: 0, y: 0 },
|
|
866
|
+
{ id: 'b', label: 'B', x: 100, y: 100 }
|
|
867
|
+
]
|
|
868
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items, centerScreenPoint: { x: 90, y: 90 } }))
|
|
869
|
+
expect(result.current.tabbableId).toBe('b')
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('defaults to the first item before the list has ever been focused', () => {
|
|
873
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
|
|
874
|
+
expect(result.current.tabbableId).toBe('a')
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
// Unlike onFocus's own resolution, tabbableId deliberately ignores selection — "prefer the
|
|
878
|
+
// selected item" is a real-Tab-in-only concern. If tabbableId also preferred it, selecting a
|
|
879
|
+
// single marker by clicking it on the map (which never touches lastActiveIdRef) would relocate
|
|
880
|
+
// tabIndex to match it on every click.
|
|
881
|
+
it('is not influenced by a selection change before any position is established', () => {
|
|
882
|
+
const eb = makeEventBus()
|
|
883
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
884
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
|
|
885
|
+
expect(result.current.tabbableId).toBe('a')
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
it('does not chase a single item toggled selected/deselected purely via mouse clicks, with no keyboard interaction at all', () => {
|
|
889
|
+
const eb = makeEventBus()
|
|
890
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
891
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['a'] }))
|
|
892
|
+
expect(result.current.tabbableId).toBe('a')
|
|
893
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['b'] })) // clicked a different marker
|
|
894
|
+
expect(result.current.tabbableId).toBe('a') // still the structural first item — never moved
|
|
895
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['c'] }))
|
|
896
|
+
expect(result.current.tabbableId).toBe('a')
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
it('stays on the established position when a selection change arrives from elsewhere (e.g. clicking a marker on the map)', () => {
|
|
900
|
+
const eb = makeEventBus()
|
|
901
|
+
const refs = makeRefs()
|
|
902
|
+
const el = refs.spatialListRef.current
|
|
903
|
+
document.body.appendChild(el)
|
|
904
|
+
const { result, unmount } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS, eventBus: eb }))
|
|
905
|
+
act(() => result.current.onFocus()) // establishes position at 'a'
|
|
906
|
+
fireKey(el, 'ArrowDown') // establishes position at 'b'
|
|
907
|
+
act(() => result.current.onBlur())
|
|
908
|
+
// A selection made outside the list (e.g. a mouse click on a map marker) never touches
|
|
909
|
+
// lastActiveIdRef — it only changes selectedIds via interact:selectionchange.
|
|
910
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['c'] }))
|
|
911
|
+
expect(result.current.tabbableId).toBe('b') // stays put — does not jump to the newly-selected 'c'
|
|
912
|
+
unmount(); el.remove()
|
|
913
|
+
})
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
// ─── useSpatialListFocus — selectItem (click / touch / voice-control activation) ─
|
|
917
|
+
|
|
918
|
+
describe('useSpatialListFocus — selectItem', () => {
|
|
919
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
920
|
+
|
|
921
|
+
it('sets activeItemId to the clicked item', () => {
|
|
922
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
|
|
923
|
+
act(() => result.current.selectItem('b'))
|
|
924
|
+
expect(result.current.activeItemId).toBe('b')
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('emits map:setactivefeature then map:selectfeature for the clicked item', () => {
|
|
928
|
+
const eb = makeEventBus()
|
|
929
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
930
|
+
act(() => result.current.selectItem('b'))
|
|
931
|
+
expect(eb.emit).toHaveBeenNthCalledWith(1, SET_ACTIVE, { id: 'b' })
|
|
932
|
+
expect(eb.emit).toHaveBeenNthCalledWith(2, SELECT)
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
it('moves real DOM focus to the clicked option', () => {
|
|
936
|
+
const refs = makeRefs()
|
|
937
|
+
const option = document.createElement('li')
|
|
938
|
+
option.dataset.id = 'b'
|
|
939
|
+
option.focus = jest.fn()
|
|
940
|
+
refs.spatialListRef.current.appendChild(option)
|
|
941
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
942
|
+
act(() => result.current.selectItem('b'))
|
|
943
|
+
expect(option.focus).toHaveBeenCalledWith({ preventScroll: true })
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
it('does not throw when eventBus is not provided', () => {
|
|
947
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...makeRefs(), items: ITEMS }))
|
|
948
|
+
expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
it('does not throw when spatialListRef.current is null (e.g. unmounted)', () => {
|
|
952
|
+
const refs = { ...makeRefs(), spatialListRef: { current: null } }
|
|
953
|
+
const { result } = renderHook(() => useSpatialListFocus({ ...refs, items: ITEMS }))
|
|
954
|
+
expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
|
|
955
|
+
expect(result.current.activeItemId).toBe('b') // state still updates even though there's no element to focus
|
|
956
|
+
})
|
|
957
|
+
})
|