@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,56 @@
|
|
|
1
|
+
import { classifyPanel, getPanelRole } from './getPanelRole.js'
|
|
2
|
+
|
|
3
|
+
describe('classifyPanel', () => {
|
|
4
|
+
it('is a dialog when dismissible and not a persistent side aside', () => {
|
|
5
|
+
expect(classifyPanel({ slot: 'right-top', open: false })).toMatchObject({
|
|
6
|
+
isModal: false,
|
|
7
|
+
isAside: false,
|
|
8
|
+
isDismissible: true,
|
|
9
|
+
isDialog: true
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('is an aside (not a dialog) for a persistently open side panel', () => {
|
|
14
|
+
expect(classifyPanel({ slot: 'side', open: true })).toMatchObject({
|
|
15
|
+
isAside: true,
|
|
16
|
+
isDialog: false
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('is never an aside when modal, even in the side slot', () => {
|
|
21
|
+
expect(classifyPanel({ slot: 'side', open: true, modal: true })).toMatchObject({
|
|
22
|
+
isModal: true,
|
|
23
|
+
isAside: false,
|
|
24
|
+
isDismissible: true,
|
|
25
|
+
isDialog: true
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('treats dismissible: false as non-dismissible unless modal forces it', () => {
|
|
30
|
+
expect(classifyPanel({ slot: 'right-top', open: false, dismissible: false })).toMatchObject({
|
|
31
|
+
isDismissible: false,
|
|
32
|
+
isDialog: false
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('modal forces dismissible regardless of an explicit dismissible: false', () => {
|
|
37
|
+
expect(classifyPanel({ slot: 'right-top', open: false, dismissible: false, modal: true })).toMatchObject({
|
|
38
|
+
isDismissible: true,
|
|
39
|
+
isDialog: true
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('getPanelRole', () => {
|
|
45
|
+
it('returns dialog when isDialog', () => {
|
|
46
|
+
expect(getPanelRole({ isDialog: true, isDismissible: true })).toBe('dialog')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns complementary when dismissible but not a dialog', () => {
|
|
50
|
+
expect(getPanelRole({ isDialog: false, isDismissible: true })).toBe('complementary')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns region when neither a dialog nor dismissible', () => {
|
|
54
|
+
expect(getPanelRole({ isDialog: false, isDismissible: false })).toBe('region')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Alt+<key> keyup combos useKeyboardShortcuts.js binds globally (see keyboardMappings.js's
|
|
2
|
+
// `keyup` section) — a locally-focused context using the same combo must shadow it, or both
|
|
3
|
+
// fire. Single source of truth so a new global mapping is a visible prompt to check for that.
|
|
4
|
+
export const GLOBAL_ALT_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'])
|
|
5
|
+
|
|
6
|
+
// Call unconditionally from a local keyup handler while its scope is active — it only acts
|
|
7
|
+
// when the key is actually shadowed, so it's safe to call regardless of which key was pressed.
|
|
8
|
+
export const stopIfGlobalAltKey = (event) => {
|
|
9
|
+
if (event.altKey && GLOBAL_ALT_KEYS.has(event.key)) {
|
|
10
|
+
event.stopPropagation()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { GLOBAL_ALT_KEYS, stopIfGlobalAltKey } from './globalAltShortcuts.js'
|
|
2
|
+
|
|
3
|
+
describe('GLOBAL_ALT_KEYS', () => {
|
|
4
|
+
test('lists the arrow keys and Enter — the combos keyboardMappings.js binds globally under Alt', () => {
|
|
5
|
+
expect([...GLOBAL_ALT_KEYS].sort()).toEqual(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Enter'].sort())
|
|
6
|
+
})
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe('stopIfGlobalAltKey', () => {
|
|
10
|
+
test.each(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'])('stops propagation for Alt+%s', (key) => {
|
|
11
|
+
const event = { altKey: true, key, stopPropagation: jest.fn() }
|
|
12
|
+
stopIfGlobalAltKey(event)
|
|
13
|
+
expect(event.stopPropagation).toHaveBeenCalled()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('leaves the same keys alone without Alt held', () => {
|
|
17
|
+
const event = { altKey: false, key: 'ArrowUp', stopPropagation: jest.fn() }
|
|
18
|
+
stopIfGlobalAltKey(event)
|
|
19
|
+
expect(event.stopPropagation).not.toHaveBeenCalled()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('leaves an Alt+<other key> alone — nothing global uses this combo', () => {
|
|
23
|
+
const event = { altKey: true, key: 'i', stopPropagation: jest.fn() }
|
|
24
|
+
stopIfGlobalAltKey(event)
|
|
25
|
+
expect(event.stopPropagation).not.toHaveBeenCalled()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given a starting screen pixel and a set of candidate pixels, finds the index of the
|
|
3
|
+
* candidate that's nearest to `start` in the given cardinal direction — the shared primitive
|
|
4
|
+
* behind every directional "move to the next spatially-adjacent thing" interaction in this
|
|
5
|
+
* codebase (map label navigation, draw/edit vertex navigation, and the map features listbox).
|
|
6
|
+
*
|
|
7
|
+
* Candidates are first filtered to a ~90° cone centred on the pressed direction (whichever
|
|
8
|
+
* axis dominates the offset decides up/down vs left/right), then the nearest of those by
|
|
9
|
+
* Euclidean distance wins. If nothing falls in that cone, `start`'s own index in `pixels` is
|
|
10
|
+
* returned (a no-op move) rather than jumping somewhere the user didn't ask for.
|
|
11
|
+
*
|
|
12
|
+
* Previously duplicated (with subtly different tie-breaking/equality logic) between
|
|
13
|
+
* providers/maplibre/src/utils/spatial.js and plugins/draw/src/utils/spatial.js —
|
|
14
|
+
* consolidated here as the one canonical implementation both now import.
|
|
15
|
+
*
|
|
16
|
+
* @param {[number, number]} start - Current pixel [x, y].
|
|
17
|
+
* @param {Array<[number, number]>} pixels - All candidate pixels, including `start` itself.
|
|
18
|
+
* @param {'ArrowUp'|'ArrowDown'|'ArrowLeft'|'ArrowRight'|undefined} direction - undefined matches every candidate (nearest overall, any direction).
|
|
19
|
+
* @returns {number} Index into `pixels` of the chosen candidate.
|
|
20
|
+
*/
|
|
21
|
+
export const spatialNavigate = (start, pixels, direction) => {
|
|
22
|
+
const isSamePixel = (a, b) => a[0] === b[0] && a[1] === b[1]
|
|
23
|
+
|
|
24
|
+
const quadrant = pixels.filter((p) => {
|
|
25
|
+
const offsetX = Math.abs(p[0] - start[0])
|
|
26
|
+
const offsetY = Math.abs(p[1] - start[1])
|
|
27
|
+
let isQuadrant = false
|
|
28
|
+
if (direction === 'ArrowUp') {
|
|
29
|
+
isQuadrant = p[1] <= start[1] && offsetY >= offsetX
|
|
30
|
+
} else if (direction === 'ArrowDown') {
|
|
31
|
+
isQuadrant = p[1] > start[1] && offsetY >= offsetX
|
|
32
|
+
} else if (direction === 'ArrowLeft') {
|
|
33
|
+
isQuadrant = p[0] <= start[0] && offsetY < offsetX
|
|
34
|
+
} else if (direction === 'ArrowRight') {
|
|
35
|
+
isQuadrant = p[0] > start[0] && offsetY < offsetX
|
|
36
|
+
} else {
|
|
37
|
+
isQuadrant = true
|
|
38
|
+
}
|
|
39
|
+
return isQuadrant && !isSamePixel(p, start)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (!quadrant.length) {
|
|
43
|
+
quadrant.push(start)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const distance = (p) => Math.hypot(start[0] - p[0], start[1] - p[1])
|
|
47
|
+
const closest = quadrant.reduce((best, p) => (distance(p) < distance(best) ? p : best), quadrant[0])
|
|
48
|
+
return pixels.findIndex((p) => isSamePixel(p, closest))
|
|
49
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spatialNavigate } from './spatialNavigate.js'
|
|
2
|
+
|
|
3
|
+
describe('spatialNavigate', () => {
|
|
4
|
+
const start = [0, 0]
|
|
5
|
+
const pixels = [[0, 0], [0, -10], [0, 10], [-10, 0], [10, 0]]
|
|
6
|
+
|
|
7
|
+
test.each([
|
|
8
|
+
['ArrowUp', 1],
|
|
9
|
+
['ArrowDown', 2],
|
|
10
|
+
['ArrowLeft', 3],
|
|
11
|
+
['ArrowRight', 4]
|
|
12
|
+
])('finds the nearest pixel for %s', (direction, expectedIndex) => {
|
|
13
|
+
expect(spatialNavigate(start, pixels, direction)).toBe(expectedIndex)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('considers all pixels for an unrecognised direction', () => {
|
|
17
|
+
expect(spatialNavigate(start, pixels, 'Tab')).toBe(1)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('considers all pixels when no direction is given (nearest overall)', () => {
|
|
21
|
+
expect(spatialNavigate(start, pixels, undefined)).toBe(1)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('falls back to the start point when no pixel is in the quadrant', () => {
|
|
25
|
+
expect(spatialNavigate(start, [[0, 0]], 'ArrowUp')).toBe(0)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('picks the closer of two candidates in the same direction', () => {
|
|
29
|
+
const pixels = [[0, 0], [10, 0], [2, 0]]
|
|
30
|
+
expect(spatialNavigate(start, pixels, 'ArrowRight')).toBe(2)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('keeps the first-seen closer candidate rather than a later farther one', () => {
|
|
34
|
+
const pixels = [[0, 0], [2, 0], [10, 0]]
|
|
35
|
+
expect(spatialNavigate(start, pixels, 'ArrowRight')).toBe(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('a diagonal candidate with dx > dy counts as ArrowRight, not ArrowUp/Down', () => {
|
|
39
|
+
const pixels = [[0, 0], [3, 1], [1, 0]]
|
|
40
|
+
expect(spatialNavigate(start, pixels, 'ArrowRight')).toBe(2)
|
|
41
|
+
})
|
|
42
|
+
})
|
package/webpack.umd.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'url'
|
|
|
3
3
|
import fs from 'fs'
|
|
4
4
|
|
|
5
5
|
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
|
|
6
|
+
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'
|
|
6
7
|
import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts'
|
|
7
8
|
import RemoveFilesPlugin from 'remove-files-webpack-plugin'
|
|
8
9
|
|
|
@@ -124,7 +125,11 @@ const createUMDConfig = (entryName, entryPath, libraryPath, outDir, isCore = fal
|
|
|
124
125
|
|
|
125
126
|
optimization: {
|
|
126
127
|
splitChunks: { chunks: () => false },
|
|
127
|
-
removeEmptyChunks: true
|
|
128
|
+
removeEmptyChunks: true,
|
|
129
|
+
// '...' keeps webpack's default JS minimizer (Terser); CssMinimizerPlugin
|
|
130
|
+
// is added alongside it since setting `minimizer` explicitly replaces the
|
|
131
|
+
// defaults otherwise.
|
|
132
|
+
minimizer: ['...', new CssMinimizerPlugin()]
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
}
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react'
|
|
2
|
-
import { EVENTS } from '../../../../src/config/events.js'
|
|
3
|
-
import { isStandaloneLabel } from '../../../../src/utils/symbolUtils.js'
|
|
4
|
-
import { buildLayerConfigMap } from '../utils/featureQueries.js'
|
|
5
|
-
|
|
6
|
-
const getFeatureId = (feature, config) =>
|
|
7
|
-
config ? (feature.properties?.[config.idProperty] ?? feature.id) : null
|
|
8
|
-
|
|
9
|
-
const isInViewport = (el) => {
|
|
10
|
-
const container = el.closest('.im-c-viewport__markers')
|
|
11
|
-
if (!container) {
|
|
12
|
-
return false
|
|
13
|
-
}
|
|
14
|
-
const containerRect = container.getBoundingClientRect()
|
|
15
|
-
const { left, right, top, bottom } = el.getBoundingClientRect()
|
|
16
|
-
return right > containerRect.left && left < containerRect.right &&
|
|
17
|
-
bottom > containerRect.top && top < containerRect.bottom
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const collectVisibleMarkers = (markers) => {
|
|
21
|
-
const items = []
|
|
22
|
-
for (const marker of markers.items) {
|
|
23
|
-
if (!marker.label) { continue }
|
|
24
|
-
const el = markers.markerRefs?.get(marker.id)
|
|
25
|
-
if (!isStandaloneLabel(marker) && el && isInViewport(el)) {
|
|
26
|
-
items.push({ id: marker.id, label: marker.label })
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
return items
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const toFeatureItem = (feature, layerConfigMap, seenIds) => {
|
|
33
|
-
const config = layerConfigMap[feature.layer?.id]
|
|
34
|
-
if (!config?.labelProperty) {
|
|
35
|
-
return null
|
|
36
|
-
}
|
|
37
|
-
const id = getFeatureId(feature, config)
|
|
38
|
-
const stringId = id == null ? null : String(id)
|
|
39
|
-
if (stringId == null || seenIds.has(stringId)) {
|
|
40
|
-
return null
|
|
41
|
-
}
|
|
42
|
-
seenIds.add(stringId)
|
|
43
|
-
const label = feature.properties?.[config.labelProperty] ?? stringId
|
|
44
|
-
return { id: stringId, label }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const collectVisibleFeatures = (mapProvider, layers) => {
|
|
48
|
-
const items = []
|
|
49
|
-
const seenIds = new Set()
|
|
50
|
-
const layerIds = layers.map(layer => layer.layerId)
|
|
51
|
-
const layerConfigMap = buildLayerConfigMap(layers)
|
|
52
|
-
const features = mapProvider.getVisibleFeatures(layerIds)
|
|
53
|
-
for (const feature of features) {
|
|
54
|
-
const item = toFeatureItem(feature, layerConfigMap, seenIds)
|
|
55
|
-
if (item) {
|
|
56
|
-
items.push(item)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return items
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const findFeatureById = (features, layerConfigMap, targetId) => {
|
|
63
|
-
for (const feature of features) {
|
|
64
|
-
const config = layerConfigMap[feature.layer?.id]
|
|
65
|
-
const rawId = getFeatureId(feature, config)
|
|
66
|
-
if (rawId != null && String(rawId) === String(targetId)) {
|
|
67
|
-
return { feature, config, rawId }
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return null
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Rebuilds the keyboard-navigable item list whenever the map moves or data changes.
|
|
75
|
-
* Collects visible markers (by DOM visibility) and visible features (by viewport query),
|
|
76
|
-
* then emits MAP_SET_FEATURES so the listbox stays in sync with what the user can see.
|
|
77
|
-
*/
|
|
78
|
-
function useItemListSync ({ markers, interactionModes, layers, mapProvider, multiSelect, eventBus }) {
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
const handleMoveEnd = () => {
|
|
81
|
-
const items = []
|
|
82
|
-
if (interactionModes?.includes('selectMarker')) {
|
|
83
|
-
items.push(...collectVisibleMarkers(markers))
|
|
84
|
-
}
|
|
85
|
-
if (interactionModes?.includes('selectFeature') && layers.length > 0) {
|
|
86
|
-
items.push(...collectVisibleFeatures(mapProvider, layers))
|
|
87
|
-
}
|
|
88
|
-
eventBus.emit(EVENTS.MAP_SET_FEATURES, { items, multiselectable: multiSelect })
|
|
89
|
-
}
|
|
90
|
-
handleMoveEnd()
|
|
91
|
-
eventBus.on(EVENTS.MAP_MOVE_END, handleMoveEnd)
|
|
92
|
-
eventBus.on(EVENTS.MAP_DATA_CHANGE, handleMoveEnd)
|
|
93
|
-
return () => {
|
|
94
|
-
eventBus.off(EVENTS.MAP_MOVE_END, handleMoveEnd)
|
|
95
|
-
eventBus.off(EVENTS.MAP_DATA_CHANGE, handleMoveEnd)
|
|
96
|
-
}
|
|
97
|
-
}, [markers, interactionModes, layers, mapProvider, multiSelect, eventBus])
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Listens for MAP_SET_ACTIVE_FEATURE and resolves the active item to its full feature/marker data,
|
|
102
|
-
* storing it in both a ref (for synchronous access) and plugin state (for highlight rendering).
|
|
103
|
-
* Shows the keyboard cursor ring without firing interact:selectionchange — committing the item
|
|
104
|
-
* to the real selection only happens when the user presses Enter/Space.
|
|
105
|
-
*/
|
|
106
|
-
function useActiveItemHandler ({ markers, interactionModes, layers, mapProvider, eventBus, dispatch, listboxActiveItemRef }) {
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
const handle = ({ id }) => {
|
|
109
|
-
if (id === null) {
|
|
110
|
-
listboxActiveItemRef.current = null
|
|
111
|
-
dispatch({ type: 'SET_LISTBOX_ACTIVE', payload: null })
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
const hasMarkerMatch = markers.items.some(m => m.id === id)
|
|
115
|
-
if (hasMarkerMatch) {
|
|
116
|
-
listboxActiveItemRef.current = { id, isMarker: true }
|
|
117
|
-
dispatch({ type: 'SET_LISTBOX_ACTIVE', payload: null })
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
if (interactionModes?.includes('selectFeature') && layers.length > 0) {
|
|
121
|
-
const layerIds = layers.map(layer => layer.layerId)
|
|
122
|
-
const layerConfigMap = buildLayerConfigMap(layers)
|
|
123
|
-
const features = mapProvider.getVisibleFeatures(layerIds)
|
|
124
|
-
const match = findFeatureById(features, layerConfigMap, id)
|
|
125
|
-
if (match) {
|
|
126
|
-
const payload = {
|
|
127
|
-
featureId: match.rawId,
|
|
128
|
-
layerId: match.config.layerId,
|
|
129
|
-
idProperty: match.config.idProperty,
|
|
130
|
-
geometry: match.feature.geometry
|
|
131
|
-
}
|
|
132
|
-
listboxActiveItemRef.current = { id, isMarker: false, ...payload, properties: match.feature.properties }
|
|
133
|
-
dispatch({ type: 'SET_LISTBOX_ACTIVE', payload })
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
eventBus.on(EVENTS.MAP_SET_ACTIVE_FEATURE, handle)
|
|
138
|
-
return () => { eventBus.off(EVENTS.MAP_SET_ACTIVE_FEATURE, handle) }
|
|
139
|
-
}, [markers, interactionModes, layers, mapProvider, eventBus, dispatch, listboxActiveItemRef])
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Handles MAP_SELECT_FEATURE (Enter/Space keypress) by promoting the currently active
|
|
144
|
-
* listbox item to a confirmed selection, dispatching TOGGLE_SELECTED_FEATURES or
|
|
145
|
-
* TOGGLE_SELECTED_MARKERS and triggering interact:selectionchange downstream.
|
|
146
|
-
*/
|
|
147
|
-
function useSelectItemHandler ({ eventBus, dispatch, listboxActiveItemRef, multiSelect }) {
|
|
148
|
-
useEffect(() => {
|
|
149
|
-
const handleConfirm = () => {
|
|
150
|
-
const item = listboxActiveItemRef.current
|
|
151
|
-
if (!item) {
|
|
152
|
-
return
|
|
153
|
-
}
|
|
154
|
-
if (item.isMarker) {
|
|
155
|
-
dispatch({ type: 'TOGGLE_SELECTED_MARKERS', payload: { markerId: item.id, multiSelect } })
|
|
156
|
-
} else {
|
|
157
|
-
const { featureId, layerId, idProperty, geometry, properties } = item
|
|
158
|
-
dispatch({
|
|
159
|
-
type: 'TOGGLE_SELECTED_FEATURES',
|
|
160
|
-
payload: { featureId, layerId, idProperty, geometry, properties, multiSelect, replaceAll: !multiSelect }
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
eventBus.on(EVENTS.MAP_SELECT_FEATURE, handleConfirm)
|
|
165
|
-
return () => { eventBus.off(EVENTS.MAP_SELECT_FEATURE, handleConfirm) }
|
|
166
|
-
}, [eventBus, dispatch, listboxActiveItemRef, multiSelect])
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Orchestrates the keyboard-accessible listbox for the interact plugin.
|
|
171
|
-
*
|
|
172
|
-
* Composes three concerns:
|
|
173
|
-
* - Item list sync — keeps the listbox populated with currently visible markers and features
|
|
174
|
-
* - Active item resolution — translates a listbox cursor position into full feature/marker data
|
|
175
|
-
* - Selection confirmation — commits the active item to the selection on Enter/Space
|
|
176
|
-
*
|
|
177
|
-
* @param {{ mapState: object, pluginState: object, services: object, mapProvider: object }} params
|
|
178
|
-
*/
|
|
179
|
-
export function useMapItemList ({ mapState, pluginState, services, mapProvider }) {
|
|
180
|
-
const { markers } = mapState
|
|
181
|
-
const { dispatch, interactionModes, layers, multiSelect } = pluginState
|
|
182
|
-
const { eventBus } = services
|
|
183
|
-
const listboxActiveItemRef = useRef(null)
|
|
184
|
-
useItemListSync({ markers, interactionModes, layers, mapProvider, multiSelect, eventBus })
|
|
185
|
-
useActiveItemHandler({ markers, interactionModes, layers, mapProvider, eventBus, dispatch, listboxActiveItemRef })
|
|
186
|
-
useSelectItemHandler({ eventBus, dispatch, listboxActiveItemRef, multiSelect })
|
|
187
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import React, { forwardRef } from 'react'
|
|
2
|
-
import { useConfig } from '../../store/configContext.js'
|
|
3
|
-
|
|
4
|
-
export const Features = forwardRef(({ activeFeatureId, selectedIds = [], multiselectable = false, items = [], onFocus, onBlur }, ref) => {
|
|
5
|
-
const { id } = useConfig()
|
|
6
|
-
const hasItems = items.length > 0
|
|
7
|
-
return (
|
|
8
|
-
<ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
|
|
9
|
-
id={`${id}-features`}
|
|
10
|
-
ref={ref}
|
|
11
|
-
role='listbox' // NOSONAR
|
|
12
|
-
tabIndex={hasItems ? '0' : '-1'}
|
|
13
|
-
aria-hidden={hasItems ? undefined : true}
|
|
14
|
-
aria-label='Map features'
|
|
15
|
-
aria-describedby={`${id}-keyboard-desc`}
|
|
16
|
-
aria-multiselectable={multiselectable || undefined}
|
|
17
|
-
aria-activedescendant={activeFeatureId ? `${id}-feature-${activeFeatureId}` : undefined}
|
|
18
|
-
className='im-c-features'
|
|
19
|
-
onFocus={onFocus}
|
|
20
|
-
onBlur={onBlur}
|
|
21
|
-
>
|
|
22
|
-
{items.map(item => (
|
|
23
|
-
<li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern
|
|
24
|
-
key={item.id} id={`${id}-feature-${item.id}`} role='option' // NOSONAR
|
|
25
|
-
data-id={item.id}
|
|
26
|
-
aria-selected={selectedIds.includes(item.id)}
|
|
27
|
-
>
|
|
28
|
-
{item.label}
|
|
29
|
-
</li>
|
|
30
|
-
))}
|
|
31
|
-
</ul>
|
|
32
|
-
)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
Features.displayName = 'Features'
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { render, fireEvent } from '@testing-library/react'
|
|
3
|
-
import { Features } from './Features.jsx'
|
|
4
|
-
import { useConfig } from '../../store/configContext.js'
|
|
5
|
-
|
|
6
|
-
jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
|
|
7
|
-
|
|
8
|
-
const APP_ID = 'test-app'
|
|
9
|
-
const LISTBOX = '[role="listbox"]' // NOSONAR
|
|
10
|
-
const OPTION = '[role="option"]' // NOSONAR
|
|
11
|
-
const ARIA_SELECTED = 'aria-selected'
|
|
12
|
-
const ITEMS = [
|
|
13
|
-
{ id: 'f1', label: 'Feature One' },
|
|
14
|
-
{ id: 'f2', label: 'Feature Two' }
|
|
15
|
-
]
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
useConfig.mockReturnValue({ id: APP_ID })
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
// ─── Features — rendering ─────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
describe('Features — rendering', () => {
|
|
24
|
-
it('renders a listbox with the correct id', () => {
|
|
25
|
-
const { container } = render(<Features />)
|
|
26
|
-
expect(container.querySelector(`#${APP_ID}-features`)).toBeTruthy()
|
|
27
|
-
expect(container.querySelector(LISTBOX)).toBeTruthy() // NOSONAR
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('renders no options when items is empty', () => {
|
|
31
|
-
const { container } = render(<Features />)
|
|
32
|
-
expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('renders one option per item with correct id, data-id and label', () => {
|
|
36
|
-
const { container } = render(<Features items={ITEMS} />)
|
|
37
|
-
const options = container.querySelectorAll(OPTION)
|
|
38
|
-
expect(options).toHaveLength(2)
|
|
39
|
-
expect(options[0].getAttribute('id')).toBe(`${APP_ID}-feature-f1`)
|
|
40
|
-
expect(options[0].dataset.id).toBe('f1')
|
|
41
|
-
expect(options[0].textContent).toBe('Feature One')
|
|
42
|
-
expect(options[1].getAttribute('id')).toBe(`${APP_ID}-feature-f2`)
|
|
43
|
-
expect(options[1].dataset.id).toBe('f2')
|
|
44
|
-
expect(options[1].textContent).toBe('Feature Two')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('sets aria-selected on items present in selectedIds', () => {
|
|
48
|
-
const { container } = render(<Features items={ITEMS} selectedIds={['f1']} />)
|
|
49
|
-
const options = container.querySelectorAll(OPTION)
|
|
50
|
-
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
51
|
-
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('sets aria-selected on multiple items when selectedIds has multiple entries', () => {
|
|
55
|
-
const { container } = render(<Features items={ITEMS} selectedIds={['f1', 'f2']} />)
|
|
56
|
-
const options = container.querySelectorAll(OPTION)
|
|
57
|
-
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
58
|
-
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('does not set aria-selected from activeFeatureId alone', () => {
|
|
62
|
-
const { container } = render(<Features items={ITEMS} activeFeatureId='f1' />)
|
|
63
|
-
const options = container.querySelectorAll(OPTION)
|
|
64
|
-
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
65
|
-
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
it('sets aria-activedescendant to the option element id when activeFeatureId is provided', () => {
|
|
69
|
-
const { container } = render(<Features items={ITEMS} activeFeatureId='f2' />)
|
|
70
|
-
expect(container.querySelector(LISTBOX).getAttribute('aria-activedescendant')).toBe('test-app-feature-f2') // NOSONAR
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('omits aria-activedescendant when activeFeatureId is absent', () => {
|
|
74
|
-
const { container } = render(<Features items={ITEMS} />)
|
|
75
|
-
expect(container.querySelector(LISTBOX).getAttribute('aria-activedescendant')).toBeNull() // NOSONAR
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('sets aria-multiselectable when multiselectable is true', () => {
|
|
79
|
-
const { container } = render(<Features items={ITEMS} multiselectable />)
|
|
80
|
-
expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBe('true') // NOSONAR
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('omits aria-multiselectable when multiselectable is false', () => {
|
|
84
|
-
const { container } = render(<Features items={ITEMS} />)
|
|
85
|
-
expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBeNull() // NOSONAR
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('is tabIndex 0 and not aria-hidden when items are present', () => {
|
|
89
|
-
const { container } = render(<Features items={ITEMS} />)
|
|
90
|
-
const ul = container.querySelector(LISTBOX) // NOSONAR
|
|
91
|
-
expect(ul.getAttribute('tabIndex')).toBe('0')
|
|
92
|
-
expect(ul.getAttribute('aria-hidden')).toBeNull()
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('is tabIndex -1 and aria-hidden when items is empty', () => {
|
|
96
|
-
const { container } = render(<Features />)
|
|
97
|
-
const ul = container.querySelector(LISTBOX) // NOSONAR
|
|
98
|
-
expect(ul.getAttribute('tabIndex')).toBe('-1')
|
|
99
|
-
expect(ul.getAttribute('aria-hidden')).toBe('true')
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('sets aria-describedby to the shared hints container id', () => {
|
|
103
|
-
const { container } = render(<Features />)
|
|
104
|
-
expect(container.querySelector(LISTBOX).getAttribute('aria-describedby')).toBe(`${APP_ID}-keyboard-desc`) // NOSONAR
|
|
105
|
-
})
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
// ─── Features — interactions ──────────────────────────────────────────────────
|
|
109
|
-
|
|
110
|
-
describe('Features — interactions', () => {
|
|
111
|
-
it('calls onFocus when the listbox receives focus', () => {
|
|
112
|
-
const onFocus = jest.fn()
|
|
113
|
-
const { container } = render(<Features onFocus={onFocus} />)
|
|
114
|
-
fireEvent.focus(container.querySelector(LISTBOX)) // NOSONAR
|
|
115
|
-
expect(onFocus).toHaveBeenCalled()
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('calls onBlur when the listbox loses focus', () => {
|
|
119
|
-
const onBlur = jest.fn()
|
|
120
|
-
const { container } = render(<Features onBlur={onBlur} />)
|
|
121
|
-
fireEvent.blur(container.querySelector(LISTBOX)) // NOSONAR
|
|
122
|
-
expect(onBlur).toHaveBeenCalled()
|
|
123
|
-
})
|
|
124
|
-
})
|