@defra/interactive-map 0.0.43-fmp-experimental → 0.0.44-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/templates/add-polygons.njk +5 -1
- package/assets/templates/add-symbols.njk +5 -1
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/context.md +24 -0
- package/docs/api/keyboard-shortcut-definition.md +102 -0
- package/docs/api.md +18 -8
- package/docs/building-a-plugin.md +1 -0
- package/docs/plugins/draw.md +2 -2
- package/docs/plugins/plugin-manifest.md +9 -0
- package/package.json +4 -2
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
- package/plugins/beta/draw-es/src/events.js +17 -8
- package/plugins/beta/draw-es/src/reducer.js +8 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ol/dist/css/index.css +1 -13
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
- package/plugins/beta/frame/dist/css/index.css +1 -11
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/src/Frame.jsx +28 -2
- package/plugins/beta/map-styles/dist/css/index.css +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/beta/scale-bar/dist/css/index.css +1 -8
- package/plugins/datasets/dist/css/5648.index.css +1 -5
- package/plugins/datasets/dist/css/index.css +1 -1
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
- package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
- package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
- package/plugins/datasets/src/registry/dataset.js +8 -12
- package/plugins/datasets/src/registry/dataset.test.js +17 -0
- package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
- package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/esm/index.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/index.js +1 -1
- package/plugins/draw/src/DrawInit.jsx +41 -26
- package/plugins/draw/src/DrawInit.test.jsx +55 -12
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
- package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
- package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
- package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
- package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
- package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
- package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
- package/plugins/draw/src/events.js +4 -4
- package/plugins/draw/src/events.test.js +1 -1
- package/plugins/draw/src/hooks/useSpatialList.js +180 -0
- package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
- package/plugins/draw/src/manifest.js +4 -1
- package/plugins/draw/src/manifest.test.js +19 -7
- package/plugins/draw/src/utils/spatial.js +1 -31
- package/plugins/draw/src/utils/spatial.test.js +1 -24
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +2 -2
- package/plugins/interact/src/InteractInit.test.js +3 -3
- package/plugins/interact/src/events.js +11 -2
- package/plugins/interact/src/events.test.js +32 -0
- package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
- package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
- package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
- package/plugins/interact/src/hooks/useSpatialList.js +251 -0
- package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
- package/plugins/interact/src/manifest.js +12 -1
- package/plugins/interact/src/manifest.test.js +20 -0
- package/plugins/interact/src/utils/spatial.js +24 -1
- package/plugins/interact/src/utils/spatial.test.js +26 -1
- package/plugins/map-key/dist/css/index.css +1 -106
- package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/index.js +1 -1
- package/plugins/map-key/src/components/Key/Key.jsx +3 -4
- package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
- package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
- package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
- package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
- package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
- package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
- package/plugins/map-key/src/manifest.js +1 -1
- package/plugins/map-key/src/utils/groupStyles.js +7 -0
- package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
- package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
- package/plugins/menu/dist/css/index.css +1 -80
- package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
- package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
- package/plugins/menu/dist/umd/index.js +1 -1
- package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
- package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
- package/plugins/menu/src/initialise/createMenu.js +3 -1
- package/plugins/menu/src/initialise/createMenu.test.js +20 -5
- package/plugins/menu/src/initialise/updateUrl.js +44 -0
- package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
- package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
- package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
- package/plugins/menu/src/reducers/pluginState.js +1 -6
- package/plugins/menu/src/reducers/pluginState.test.js +0 -25
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/manifest.js +5 -9
- package/plugins/search/src/manifest.test.js +5 -0
- package/providers/beta/esri/dist/css/index.css +1 -34
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
- package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
- package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/defaults.test.js +26 -0
- package/providers/maplibre/src/utils/labels.js +2 -2
- package/providers/maplibre/src/utils/labels.test.js +5 -5
- package/providers/maplibre/src/utils/spatial.js +0 -57
- package/providers/maplibre/src/utils/spatial.test.js +0 -27
- package/rollup.esm.mjs +2 -1
- package/scripts/publish-package.sh +36 -0
- package/src/App/App.jsx +1 -1
- package/src/App/App.test.jsx +6 -1
- package/src/App/components/CrossHair/CrossHair.jsx +25 -11
- package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
- package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
- package/src/App/components/Hints/Hints.jsx +3 -3
- package/src/App/components/Hints/Hints.test.jsx +3 -3
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
- package/src/App/components/MapButton/MapButton.jsx +19 -7
- package/src/App/components/MapButton/MapButton.module.scss +1 -2
- package/src/App/components/MapButton/MapButton.test.jsx +42 -4
- package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
- package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
- package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
- package/src/App/components/Markers/Markers.jsx +2 -2
- package/src/App/components/Markers/Markers.test.jsx +3 -3
- package/src/App/components/Markers/SymbolMarker.jsx +2 -3
- package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
- package/src/App/components/Panel/Panel.jsx +19 -20
- package/src/App/components/Panel/Panel.module.scss +5 -0
- package/src/App/components/Panel/Panel.test.jsx +69 -4
- package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
- package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
- package/src/App/components/Viewport/MapStatus.jsx +1 -1
- package/src/App/components/Viewport/SpatialList.jsx +47 -0
- package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
- package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
- package/src/App/components/Viewport/Viewport.jsx +21 -11
- package/src/App/components/Viewport/Viewport.test.jsx +18 -2
- package/src/App/controls/keyboardMappings.js +7 -2
- package/src/App/controls/keyboardMappings.test.js +21 -0
- package/src/App/controls/keyboardShortcuts.js +16 -2
- package/src/App/controls/keyboardShortcuts.test.js +21 -0
- package/src/App/hooks/useCrossHairAPI.js +29 -1
- package/src/App/hooks/useCrossHairAPI.test.js +17 -1
- package/src/App/hooks/useHintsAPI.js +5 -5
- package/src/App/hooks/useInterfaceAPI.js +2 -1
- package/src/App/hooks/useInterfaceAPI.test.js +9 -0
- package/src/App/hooks/useKeyboardShortcuts.js +66 -35
- package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
- package/src/App/hooks/useModalPanelBehaviour.js +48 -34
- package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
- package/src/App/hooks/useSpatialListFocus.js +296 -0
- package/src/App/hooks/useSpatialListFocus.test.js +957 -0
- package/src/App/hooks/useSpatialListItems.js +51 -0
- package/src/App/hooks/useSpatialListItems.test.js +217 -0
- package/src/App/initialiseApp.js +6 -3
- package/src/App/initialiseApp.test.js +11 -0
- package/src/App/layout/Layout.jsx +4 -2
- package/src/App/layout/Layout.test.jsx +21 -0
- package/src/App/layout/layout.module.scss +7 -1
- package/src/App/registry/keyboardShortcutRegistry.js +2 -2
- package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
- package/src/App/registry/pluginRegistry.js +13 -0
- package/src/App/registry/pluginRegistry.test.js +15 -0
- package/src/App/registry/spatialListRegistry.js +104 -0
- package/src/App/registry/spatialListRegistry.test.js +218 -0
- package/src/App/renderer/HtmlElementHost.jsx +12 -32
- package/src/App/renderer/mapButtons.js +4 -0
- package/src/App/renderer/mapButtons.test.js +20 -0
- package/src/App/renderer/mapPanels.js +33 -53
- package/src/App/renderer/mapPanels.test.js +29 -4
- package/src/App/renderer/slotHelpers.js +33 -0
- package/src/App/renderer/slotHelpers.test.js +79 -2
- package/src/App/store/AppProvider.jsx +3 -2
- package/src/App/store/ServiceProvider.jsx +5 -3
- package/src/App/store/ServiceProvider.test.jsx +20 -1
- package/src/InteractiveMap/InteractiveMap.js +13 -2
- package/src/InteractiveMap/InteractiveMap.test.js +33 -0
- package/src/config/appConfig.js +16 -16
- package/src/config/appConfig.test.js +19 -19
- package/src/config/defaults.js +2 -2
- package/src/config/events.js +8 -8
- package/src/scss/main.scss +2 -2
- package/src/test-utils.js +8 -0
- package/src/types.js +51 -9
- package/src/utils/findNearestItemInDirection.js +29 -0
- package/src/utils/findNearestItemInDirection.test.js +67 -0
- package/src/utils/getPanelElementId.js +4 -0
- package/src/utils/getPanelElementId.test.js +11 -0
- package/src/utils/getPanelRole.js +22 -0
- package/src/utils/getPanelRole.test.js +56 -0
- package/src/utils/globalAltShortcuts.js +12 -0
- package/src/utils/globalAltShortcuts.test.js +27 -0
- package/src/utils/spatialNavigate.js +49 -0
- package/src/utils/spatialNavigate.test.js +42 -0
- package/webpack.umd.mjs +6 -1
- package/plugins/interact/src/hooks/useMapItemList.js +0 -187
- package/src/App/components/Viewport/Features.jsx +0 -35
- package/src/App/components/Viewport/Features.test.jsx +0 -124
- package/src/App/hooks/useFeatureFocus.js +0 -199
- package/src/App/hooks/useFeatureFocus.test.js +0 -635
- package/src/App/hooks/useFeatureItems.js +0 -39
- package/src/App/hooks/useFeatureItems.test.js +0 -155
|
@@ -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')
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { isMac } from '../../utils/isMac.js'
|
|
2
|
+
|
|
3
|
+
// getInfo uses Ctrl on Windows/Linux (Alt+letter is reserved for menu mnemonics there) and Alt/Option on Mac — keep in sync with keyboardShortcuts.js's `infoKeyHtml`.
|
|
4
|
+
const infoModifier = isMac() ? 'Alt' : 'Ctrl'
|
|
5
|
+
|
|
1
6
|
export const keyboardMappings = {
|
|
2
7
|
keydown: {
|
|
3
8
|
ArrowUp: 'panUp',
|
|
@@ -17,8 +22,8 @@ export const keyboardMappings = {
|
|
|
17
22
|
'Alt+ArrowUp': 'highlightNextLabel',
|
|
18
23
|
'Alt+ArrowDown': 'highlightNextLabel',
|
|
19
24
|
'Alt+Enter': 'highlightLabelAtCenter',
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
[`${infoModifier}+i`]: 'getInfo',
|
|
26
|
+
[`${infoModifier}+I`]: 'getInfo',
|
|
22
27
|
Escape: 'clearSelection'
|
|
23
28
|
}
|
|
24
29
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
describe('keyboardMappings', () => {
|
|
2
|
+
beforeEach(() => jest.resetModules())
|
|
3
|
+
|
|
4
|
+
const load = () => require('./keyboardMappings.js').keyboardMappings
|
|
5
|
+
|
|
6
|
+
it('binds getInfo to Alt+i/Alt+I on Mac', () => {
|
|
7
|
+
Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true })
|
|
8
|
+
const { keyup } = load()
|
|
9
|
+
expect(keyup['Alt+i']).toBe('getInfo')
|
|
10
|
+
expect(keyup['Alt+I']).toBe('getInfo')
|
|
11
|
+
expect(keyup['Ctrl+i']).toBeUndefined()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('binds getInfo to Ctrl+i/Ctrl+I on non-Mac', () => {
|
|
15
|
+
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
|
|
16
|
+
const { keyup } = load()
|
|
17
|
+
expect(keyup['Ctrl+i']).toBe('getInfo')
|
|
18
|
+
expect(keyup['Ctrl+I']).toBe('getInfo')
|
|
19
|
+
expect(keyup['Alt+i']).toBeUndefined()
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// src/controls/keyboardShortcuts.js
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { isMac } from '../../utils/isMac.js'
|
|
3
|
+
|
|
4
|
+
const altKeyHtml = isMac() ? '<kbd>Option</kbd>' : '<kbd>Alt</kbd>'
|
|
5
|
+
// getInfo uses Ctrl on Windows/Linux instead of Alt, which is reserved there for menu mnemonics.
|
|
6
|
+
const infoKeyHtml = isMac() ? '<kbd>Option</kbd>' : '<kbd>Ctrl</kbd>'
|
|
4
7
|
|
|
5
8
|
export const coreShortcuts = [
|
|
6
9
|
{
|
|
@@ -10,6 +13,17 @@ export const coreShortcuts = [
|
|
|
10
13
|
context: 'global',
|
|
11
14
|
enabled: true
|
|
12
15
|
},
|
|
16
|
+
{
|
|
17
|
+
id: 'getInfo',
|
|
18
|
+
title: 'Get current location and visible area',
|
|
19
|
+
command: `${infoKeyHtml} + <kbd>I</kbd>`,
|
|
20
|
+
enabled: true,
|
|
21
|
+
// No visual equivalent, so hide the row from sighted users but keep it discoverable via assistive tech.
|
|
22
|
+
visuallyHidden: true,
|
|
23
|
+
// Only needs getCenter/getZoom, which every map provider implements, so no adapter needs to declare it.
|
|
24
|
+
mapProviderAgnostic: true,
|
|
25
|
+
requiredConfig: ['reverseGeocodeProvider']
|
|
26
|
+
},
|
|
13
27
|
{
|
|
14
28
|
id: 'moveLarge',
|
|
15
29
|
title: 'Move in large steps',
|
|
@@ -21,4 +21,25 @@ describe('keyboardShortcuts', () => {
|
|
|
21
21
|
expect(s.command).toContain('<kbd>Alt</kbd>')
|
|
22
22
|
})
|
|
23
23
|
})
|
|
24
|
+
|
|
25
|
+
it('marks getInfo as visuallyHidden, map-provider-agnostic and requiring reverseGeocodeProvider', () => {
|
|
26
|
+
const shortcuts = load()
|
|
27
|
+
const getInfo = shortcuts.find(s => s.id === 'getInfo')
|
|
28
|
+
expect(getInfo).toMatchObject({
|
|
29
|
+
visuallyHidden: true,
|
|
30
|
+
mapProviderAgnostic: true,
|
|
31
|
+
requiredConfig: ['reverseGeocodeProvider']
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('uses Ctrl for getInfo on non-Mac and Option on Mac', () => {
|
|
36
|
+
Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true })
|
|
37
|
+
let getInfo = load().find(s => s.id === 'getInfo')
|
|
38
|
+
expect(getInfo.command).toContain('<kbd>Option</kbd>')
|
|
39
|
+
|
|
40
|
+
jest.resetModules()
|
|
41
|
+
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
|
|
42
|
+
getInfo = load().find(s => s.id === 'getInfo')
|
|
43
|
+
expect(getInfo.command).toContain('<kbd>Ctrl</kbd>')
|
|
44
|
+
})
|
|
24
45
|
})
|
|
@@ -32,7 +32,19 @@ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition
|
|
|
32
32
|
dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: true } })
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Fully hidden — display:none, not just faded — rather than staying in the accessibility
|
|
36
|
+
// tree while invisible. An earlier version kept it ax-tree-present here so Voice Control
|
|
37
|
+
// could re-summon it by name, but that made "Show Names" put a "Target" label over
|
|
38
|
+
// nothing whenever it was genuinely hidden, which is more confusing than helpful. Voice
|
|
39
|
+
// Control's real route back is opening MapControls (a normal, always-visible, always-named
|
|
40
|
+
// button) — see isMapControlsOpen below — so the crosshair itself doesn't need to stay
|
|
41
|
+
// discoverable while it's not actually there. Blocked outright while MapControls is open:
|
|
42
|
+
// that panel is a deliberate, explicit "I can't drag/pinch this map" signal from the user,
|
|
43
|
+
// which should win over whatever mouse-vs-touch/keyboard inference would otherwise decide.
|
|
35
44
|
crossHair.hide = () => {
|
|
45
|
+
if (crossHair.isMapControlsOpen) {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
36
48
|
el.style.display = 'none'
|
|
37
49
|
dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
|
|
38
50
|
}
|
|
@@ -55,7 +67,7 @@ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition
|
|
|
55
67
|
|
|
56
68
|
export const useCrossHair = () => {
|
|
57
69
|
const { mapProvider } = useConfig()
|
|
58
|
-
const { safeZoneInset } = useApp()
|
|
70
|
+
const { safeZoneInset, expandedButtons } = useApp()
|
|
59
71
|
const { eventBus } = useService()
|
|
60
72
|
const { crossHair, dispatch, mapSize } = useMap()
|
|
61
73
|
|
|
@@ -98,6 +110,22 @@ export const useCrossHair = () => {
|
|
|
98
110
|
}
|
|
99
111
|
}, [mapSize])
|
|
100
112
|
|
|
113
|
+
// A plain property on the shared crossHair object, not dispatched state — every owner
|
|
114
|
+
// (draw's per-adapter modes, interact's useCrossHairVisibility) already holds this same
|
|
115
|
+
// object, so mutating it here is all that's needed for hide()'s own guard above to see the
|
|
116
|
+
// current value live, however long ago it was last set. This alone never shows or hides
|
|
117
|
+
// anything by itself — it only ever blocks a hide() call already in flight — so it's safe to
|
|
118
|
+
// run unconditionally even when no plugin that cares about it is loaded at all.
|
|
119
|
+
//
|
|
120
|
+
// Deliberately NOT a useEffect: this component's own effects and interact's/draw's own
|
|
121
|
+
// updateCrossHair effects both react to the same expandedButtons change, and React gives no
|
|
122
|
+
// ordering guarantee between two different components' effects — if the consumer's effect
|
|
123
|
+
// (which decides to call hide()) ran before this one updated the flag, hide()'s guard would
|
|
124
|
+
// read the stale value and incorrectly block itself. Setting it directly in the render body
|
|
125
|
+
// (matches DrawInit.jsx's shouldShowCrosshairRef convention) guarantees it's current before
|
|
126
|
+
// ANY component's effects run, since React finishes rendering the whole tree first.
|
|
127
|
+
crossHair.isMapControlsOpen = expandedButtons?.has('mapControls')
|
|
128
|
+
|
|
101
129
|
return {
|
|
102
130
|
crossHair,
|
|
103
131
|
crossHairRef
|
|
@@ -35,7 +35,7 @@ describe('useCrossHair', () => {
|
|
|
35
35
|
eventBus.off = jest.fn()
|
|
36
36
|
|
|
37
37
|
useConfig.mockReturnValue({ mapProvider: mockMapProvider })
|
|
38
|
-
useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 } })
|
|
38
|
+
useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set() })
|
|
39
39
|
useService.mockReturnValue({ eventBus: mockEventBus })
|
|
40
40
|
useMap.mockReturnValue({
|
|
41
41
|
crossHair: mockCrossHair,
|
|
@@ -98,6 +98,22 @@ describe('useCrossHair', () => {
|
|
|
98
98
|
expect(mockElement.style.display).toBe('none')
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
it('hide() is a no-op while isMapControlsOpen is set', () => {
|
|
102
|
+
setup()
|
|
103
|
+
act(() => mockCrossHair.show())
|
|
104
|
+
mockCrossHair.isMapControlsOpen = true
|
|
105
|
+
|
|
106
|
+
act(() => mockCrossHair.hide())
|
|
107
|
+
expect(mockElement.style.display).toBe('block')
|
|
108
|
+
expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('keeps crossHair.isMapControlsOpen synced with expandedButtons, set synchronously during render (not a useEffect) so it is never stale relative to another component\'s own effect reacting to the same change', () => {
|
|
112
|
+
useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set(['mapControls']) })
|
|
113
|
+
setup()
|
|
114
|
+
expect(mockCrossHair.isMapControlsOpen).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
101
117
|
it('setStyle updates state', () => {
|
|
102
118
|
setup()
|
|
103
119
|
act(() => mockCrossHair.setStyle('highlighted'))
|
|
@@ -32,9 +32,9 @@ export const useHintsAPI = () => {
|
|
|
32
32
|
}, [eventBus, hints])
|
|
33
33
|
|
|
34
34
|
// Escape dismisses the active hint when the keypress originates inside this
|
|
35
|
-
// map instance. The viewport and
|
|
35
|
+
// map instance. The viewport and spatial listbox already dismiss hints on
|
|
36
36
|
// Escape within their own narrower focus scope (useKeyboardHint.js /
|
|
37
|
-
//
|
|
37
|
+
// useSpatialListFocus.js) — this covers hints shown via the public showHint()
|
|
38
38
|
// API from anywhere else in the same map's UI (e.g. a plugin button).
|
|
39
39
|
//
|
|
40
40
|
// Listening on document (rather than the map's own container) and checking
|
|
@@ -51,12 +51,12 @@ export const useHintsAPI = () => {
|
|
|
51
51
|
}, [hints])
|
|
52
52
|
|
|
53
53
|
useEffect(() => {
|
|
54
|
-
const handleKeyDown = (
|
|
55
|
-
if (
|
|
54
|
+
const handleKeyDown = (event) => {
|
|
55
|
+
if (event.key !== 'Escape' || !isHintActiveRef.current) {
|
|
56
56
|
return
|
|
57
57
|
}
|
|
58
58
|
const container = layoutRefs.appContainerRef?.current
|
|
59
|
-
if (container && !container.contains(
|
|
59
|
+
if (container && !container.contains(event.target)) {
|
|
60
60
|
return
|
|
61
61
|
}
|
|
62
62
|
hints.dismiss()
|
|
@@ -58,7 +58,8 @@ export const useInterfaceAPI = () => {
|
|
|
58
58
|
const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
|
|
59
59
|
const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
|
|
60
60
|
const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
|
|
61
|
-
const handleShowPanel = ({ id, focus = true }) =>
|
|
61
|
+
const handleShowPanel = ({ id, focus = true, triggeringElement }) =>
|
|
62
|
+
dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus, props: triggeringElement ? { triggeringElement } : undefined } })
|
|
62
63
|
const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
|
|
63
64
|
const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
|
|
64
65
|
|
|
@@ -92,6 +92,15 @@ describe('useInterfaceAPI', () => {
|
|
|
92
92
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: false } })
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
+
it('dispatches OPEN_PANEL with props.triggeringElement when one is given, so Panel can return focus to it on close', () => {
|
|
96
|
+
const triggeringElement = document.createElement('button')
|
|
97
|
+
renderHook(() => useInterfaceAPI())
|
|
98
|
+
act(() => mockEventBus.emit('app:showpanel', { id: 'panel1', triggeringElement }))
|
|
99
|
+
expect(mockDispatch).toHaveBeenCalledWith({
|
|
100
|
+
type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: true, props: { triggeringElement } }
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
95
104
|
it('dispatches CLOSE_PANEL on app:hidepanel', () => {
|
|
96
105
|
renderHook(() => useInterfaceAPI())
|
|
97
106
|
act(() => mockEventBus.emit('app:hidepanel', 'panel1'))
|