@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,218 @@
|
|
|
1
|
+
import { createSpatialListRegistry } from './spatialListRegistry.js'
|
|
2
|
+
|
|
3
|
+
const SET_FEATURES = 'map:setspatiallist'
|
|
4
|
+
|
|
5
|
+
const makeEventBus = () => ({ emit: jest.fn() })
|
|
6
|
+
|
|
7
|
+
const lastEmit = (eventBus) => eventBus.emit.mock.calls.at(-1)[1]
|
|
8
|
+
|
|
9
|
+
describe('createSpatialListRegistry', () => {
|
|
10
|
+
test('registering a provider recomputes and emits its items immediately', () => {
|
|
11
|
+
const eventBus = makeEventBus()
|
|
12
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
13
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }], multiselectable: false }) })
|
|
14
|
+
expect(eventBus.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [{ id: '1' }], multiselectable: false })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('concatenates items from every registered provider when no exclusive claim is held', () => {
|
|
18
|
+
const eventBus = makeEventBus()
|
|
19
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
20
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }) })
|
|
21
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [{ id: '2' }] }) })
|
|
22
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: '1' }, { id: '2' }], multiselectable: false })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('multiselectable is true if any additive provider wants it', () => {
|
|
26
|
+
const eventBus = makeEventBus()
|
|
27
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
28
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [], multiselectable: false }) })
|
|
29
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [], multiselectable: true }) })
|
|
30
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: true })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('getItems is called fresh on every recompute, not snapshotted at registration', () => {
|
|
34
|
+
const eventBus = makeEventBus()
|
|
35
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
36
|
+
let current = [{ id: 'first' }]
|
|
37
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: current }) })
|
|
38
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'first' }], multiselectable: false })
|
|
39
|
+
|
|
40
|
+
current = [{ id: 'second' }]
|
|
41
|
+
registry.notifyItemsChanged('a')
|
|
42
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'second' }], multiselectable: false })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('unregistering a provider removes its items from the next recompute', () => {
|
|
46
|
+
const eventBus = makeEventBus()
|
|
47
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
48
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }) })
|
|
49
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [{ id: '2' }] }) })
|
|
50
|
+
registry.unregisterItemProvider('a')
|
|
51
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: '2' }], multiselectable: false })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('notifyItemsChanged for an unregistered id is a no-op', () => {
|
|
55
|
+
const eventBus = makeEventBus()
|
|
56
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
57
|
+
registry.notifyItemsChanged('ghost')
|
|
58
|
+
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('exclusive claims', () => {
|
|
62
|
+
test('claiming exclusive ownership excludes every other provider\'s items', () => {
|
|
63
|
+
const eventBus = makeEventBus()
|
|
64
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
65
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
|
|
66
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
|
|
67
|
+
|
|
68
|
+
registry.claimExclusive('draw')
|
|
69
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'vertex-1' }], multiselectable: false })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('releasing the exclusive claim reverts to the additive merge of everyone else', () => {
|
|
73
|
+
const eventBus = makeEventBus()
|
|
74
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
75
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
|
|
76
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
|
|
77
|
+
registry.claimExclusive('draw')
|
|
78
|
+
|
|
79
|
+
registry.releaseExclusive('draw')
|
|
80
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'feature-1' }], multiselectable: false })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('releaseExclusive from a provider that does not currently hold the claim is a no-op', () => {
|
|
84
|
+
const eventBus = makeEventBus()
|
|
85
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
86
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
|
|
87
|
+
eventBus.emit.mockClear()
|
|
88
|
+
|
|
89
|
+
registry.releaseExclusive('draw') // never claimed it
|
|
90
|
+
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('a non-exclusive-owning provider\'s own changes are ignored while someone else holds the claim', () => {
|
|
94
|
+
const eventBus = makeEventBus()
|
|
95
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
96
|
+
let interactItems = [{ id: 'feature-1' }]
|
|
97
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: interactItems }) })
|
|
98
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
|
|
99
|
+
registry.claimExclusive('draw')
|
|
100
|
+
eventBus.emit.mockClear()
|
|
101
|
+
|
|
102
|
+
interactItems = [{ id: 'feature-2' }] // changed while draw holds exclusive ownership
|
|
103
|
+
registry.notifyItemsChanged('interact')
|
|
104
|
+
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('unregistering the current exclusive owner clears the claim and reverts to additive', () => {
|
|
108
|
+
const eventBus = makeEventBus()
|
|
109
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
110
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
|
|
111
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
|
|
112
|
+
registry.claimExclusive('draw')
|
|
113
|
+
|
|
114
|
+
registry.unregisterItemProvider('draw')
|
|
115
|
+
expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'feature-1' }], multiselectable: false })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('label', () => {
|
|
120
|
+
test('passes through the exclusive claimant\'s own label while its claim is held', () => {
|
|
121
|
+
const eventBus = makeEventBus()
|
|
122
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
123
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [], label: 'Map features' }) })
|
|
124
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [], label: 'Shape points' }), exclusive: true })
|
|
125
|
+
|
|
126
|
+
registry.claimExclusive('draw')
|
|
127
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'Shape points' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('uses the first additive provider\'s label (in registration order) when more than one contributes', () => {
|
|
131
|
+
const eventBus = makeEventBus()
|
|
132
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
133
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [], label: 'First' }) })
|
|
134
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [], label: 'Second' }) })
|
|
135
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'First' })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('falls back to a later provider\'s label when an earlier one declares none', () => {
|
|
139
|
+
const eventBus = makeEventBus()
|
|
140
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
141
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [] }) }) // no label
|
|
142
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [], label: 'Second' }) })
|
|
143
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'Second' })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('label is undefined when nothing is registered', () => {
|
|
147
|
+
const eventBus = makeEventBus()
|
|
148
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
149
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [] }) })
|
|
150
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('focusable', () => {
|
|
155
|
+
test('passes through the exclusive claimant\'s own focusable: false while its claim is held', () => {
|
|
156
|
+
const eventBus = makeEventBus()
|
|
157
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
158
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [] }) }) // focusable defaults true
|
|
159
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [], focusable: false }), exclusive: true })
|
|
160
|
+
|
|
161
|
+
registry.claimExclusive('draw')
|
|
162
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, focusable: false })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('reverts to undefined (consumer default: focusable) once the claim is released', () => {
|
|
166
|
+
const eventBus = makeEventBus()
|
|
167
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
168
|
+
registry.registerItemProvider('interact', { getItems: () => ({ items: [] }) })
|
|
169
|
+
registry.registerItemProvider('draw', { getItems: () => ({ items: [], focusable: false }), exclusive: true })
|
|
170
|
+
registry.claimExclusive('draw')
|
|
171
|
+
|
|
172
|
+
registry.releaseExclusive('draw')
|
|
173
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('uses the first additive provider\'s explicit focusable value when more than one contributes', () => {
|
|
177
|
+
const eventBus = makeEventBus()
|
|
178
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
179
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [] }) }) // no explicit value
|
|
180
|
+
registry.registerItemProvider('b', { getItems: () => ({ items: [], focusable: false }) })
|
|
181
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, focusable: false })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('focusable is undefined when nothing declares it', () => {
|
|
185
|
+
const eventBus = makeEventBus()
|
|
186
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
187
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [] }) })
|
|
188
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('clear removes every provider and any exclusive claim', () => {
|
|
193
|
+
const eventBus = makeEventBus()
|
|
194
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
195
|
+
registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }), exclusive: true })
|
|
196
|
+
registry.claimExclusive('a')
|
|
197
|
+
|
|
198
|
+
registry.clear()
|
|
199
|
+
eventBus.emit.mockClear()
|
|
200
|
+
registry.notifyItemsChanged('a') // 'a' is no longer registered
|
|
201
|
+
expect(eventBus.emit).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('a provider whose getItems returns nothing contributes no items and does not throw', () => {
|
|
205
|
+
const eventBus = makeEventBus()
|
|
206
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
207
|
+
expect(() => registry.registerItemProvider('a', { getItems: () => undefined })).not.toThrow()
|
|
208
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('an exclusive provider whose getItems returns nothing contributes no items while holding the claim', () => {
|
|
212
|
+
const eventBus = makeEventBus()
|
|
213
|
+
const registry = createSpatialListRegistry({ eventBus })
|
|
214
|
+
registry.registerItemProvider('draw', { getItems: () => undefined, exclusive: true })
|
|
215
|
+
expect(() => registry.claimExclusive('draw')).not.toThrow()
|
|
216
|
+
expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import React, { useRef, useLayoutEffect, useMemo } from 'react'
|
|
3
3
|
import { useApp } from '../store/appContext.js'
|
|
4
4
|
import { Panel } from '../components/Panel/Panel.jsx'
|
|
5
|
-
import { resolveTargetSlot,
|
|
6
|
-
import { allowedSlots } from './slots.js'
|
|
5
|
+
import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js'
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Maps slot names to their corresponding layout refs.
|
|
@@ -90,28 +89,13 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen,
|
|
|
90
89
|
const bpConfig = config[breakpoint]
|
|
91
90
|
const targetSlot = bpConfig ? resolveTargetSlot(bpConfig, breakpoint) : null
|
|
92
91
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// 2. Slot Validation
|
|
101
|
-
const isNextToButton = targetSlot.endsWith('-button')
|
|
102
|
-
const isSlotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton
|
|
103
|
-
|
|
104
|
-
if (!isSlotAllowed) {
|
|
105
|
-
return false
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// 3. Business Logic: Combine remaining conditions into a single "fail" check
|
|
109
|
-
const isForbiddenModal = bpConfig.modal && panelId !== allowedModalPanelId
|
|
110
|
-
const isForbiddenInline = config.inline === false && !isFullscreen
|
|
111
|
-
const isInvalidMode = !isModeAllowed(config, mode)
|
|
112
|
-
|
|
113
|
-
return !(isForbiddenModal || isForbiddenInline || isInvalidMode)
|
|
114
|
-
})()
|
|
92
|
+
// Same eligibility/modal-exclusivity rules as mapPanels.js (see slotHelpers.js), combined into
|
|
93
|
+
// one boolean since isVisible alone decides whether useDomProjection shows it.
|
|
94
|
+
const isVisible = Boolean(
|
|
95
|
+
isOpen && bpConfig && targetSlot &&
|
|
96
|
+
isPanelSlotEligible(config, { targetSlot, mode, isFullscreen }) &&
|
|
97
|
+
(!bpConfig.modal || panelId === allowedModalPanelId)
|
|
98
|
+
)
|
|
115
99
|
|
|
116
100
|
useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint)
|
|
117
101
|
|
|
@@ -181,14 +165,10 @@ export const HtmlElementHost = () => {
|
|
|
181
165
|
)
|
|
182
166
|
|
|
183
167
|
// Determine which modal panel is allowed (topmost open modal)
|
|
184
|
-
const allowedModalPanelId = useMemo(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return cfg?.modal
|
|
189
|
-
})
|
|
190
|
-
return modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR - .at() unsupported on Chrome < 92
|
|
191
|
-
}, [openPanels, panelConfig, breakpoint])
|
|
168
|
+
const allowedModalPanelId = useMemo(
|
|
169
|
+
() => getAllowedModalPanelId(openPanels, panelConfig, breakpoint),
|
|
170
|
+
[openPanels, panelConfig, breakpoint]
|
|
171
|
+
)
|
|
192
172
|
|
|
193
173
|
if (!htmlPanels.length && !htmlControls.length) {
|
|
194
174
|
return null
|
|
@@ -3,6 +3,7 @@ import { MapButton } from '../components/MapButton/MapButton.jsx'
|
|
|
3
3
|
import { allowedSlots } from './slots.js'
|
|
4
4
|
import { groupByKey } from './groupByKey.js'
|
|
5
5
|
import { orderItems } from './orderItems.js'
|
|
6
|
+
import { classifyPanel, getPanelRole } from '../../utils/getPanelRole.js'
|
|
6
7
|
import { logger } from '../../services/logger.js'
|
|
7
8
|
|
|
8
9
|
function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) {
|
|
@@ -118,6 +119,8 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) {
|
|
|
118
119
|
const bpConfig = config[appState.breakpoint] ?? {}
|
|
119
120
|
const handleClick = createButtonClickHandler(config, appState, evaluateProp)
|
|
120
121
|
const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId])
|
|
122
|
+
const panelBpConfig = config.panelId ? appState.panelConfig?.[config.panelId]?.[appState.breakpoint] : undefined
|
|
123
|
+
const panelRole = panelBpConfig ? getPanelRole(classifyPanel(panelBpConfig)) : undefined
|
|
121
124
|
|
|
122
125
|
return (
|
|
123
126
|
<MapButton
|
|
@@ -133,6 +136,7 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) {
|
|
|
133
136
|
isPressed={(config.isPressed !== undefined || config.pressedWhen) ? appState.pressedButtons.has(buttonId) : undefined}
|
|
134
137
|
isExpanded={(config.isExpanded !== undefined || config.expandedWhen) ? appState.expandedButtons.has(buttonId) : undefined}
|
|
135
138
|
isPanelOpen={isPanelOpen}
|
|
139
|
+
panelRole={panelRole}
|
|
136
140
|
onClick={handleClick}
|
|
137
141
|
panelId={config.panelId}
|
|
138
142
|
menuItems={config.menuItems}
|
|
@@ -170,6 +170,26 @@ describe('mapButtons module', () => {
|
|
|
170
170
|
expect(result.props).toMatchObject({ isDisabled: true, isHidden: true, isPressed: true, isExpanded: true })
|
|
171
171
|
})
|
|
172
172
|
|
|
173
|
+
it.each([
|
|
174
|
+
[{ slot: 'right-top', open: false }, 'dialog'],
|
|
175
|
+
[{ slot: 'right-top', open: false, dismissible: false }, 'region'],
|
|
176
|
+
[{ slot: 'side', open: true }, 'complementary']
|
|
177
|
+
])('derives panelRole %j -> %s from the target panel\'s breakpoint config', (panelBpConfig, expectedRole) => {
|
|
178
|
+
const state = { ...appState, panelConfig: { p1: { desktop: panelBpConfig } } }
|
|
179
|
+
const result = render({ ...baseBtn, panelId: 'p1' }, state)
|
|
180
|
+
expect(result.props.panelRole).toBe(expectedRole)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('leaves panelRole undefined when the button has no panelId', () => {
|
|
184
|
+
const result = render(baseBtn)
|
|
185
|
+
expect(result.props.panelRole).toBeUndefined()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('leaves panelRole undefined when the target panel has no config at this breakpoint', () => {
|
|
189
|
+
const result = render({ ...baseBtn, panelId: 'missing' })
|
|
190
|
+
expect(result.props.panelRole).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
|
|
173
193
|
it('uses empty object fallback for missing breakpoint config', () => {
|
|
174
194
|
const result = render(baseBtn, { ...appState, breakpoint: 'mobile' })
|
|
175
195
|
expect(result.props.showLabel).toBe(true)
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import React from 'react'
|
|
3
3
|
import { withPluginContexts } from './pluginWrapper.js'
|
|
4
4
|
import { Panel } from '../components/Panel/Panel.jsx'
|
|
5
|
-
import {
|
|
6
|
-
import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.js'
|
|
5
|
+
import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js'
|
|
7
6
|
import { mapControls } from './mapControls.js'
|
|
8
7
|
import { orderItems } from './orderItems.js'
|
|
9
8
|
import { groupIntoTabs } from './groupIntoTabs.js'
|
|
@@ -11,69 +10,49 @@ import { stringToKebab } from '../../utils/stringToKebab.js'
|
|
|
11
10
|
import { logger } from '../../services/logger.js'
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Maps every configured panel eligible for the given layout slot to a renderable entry — open or
|
|
14
|
+
* closed alike, so a button's aria-controls id always resolves to a stable, permanently-mounted
|
|
15
|
+
* <Panel> (hidden when closed). buildPanelBody only runs while open, so nothing expensive mounts
|
|
16
|
+
* before then.
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// Consumer HTML panels are managed by HtmlElementHost; the rest need a breakpoint config and to
|
|
19
|
+
// be eligible for this slot. Returns null (skip) or the pieces the caller needs.
|
|
20
|
+
const getEligiblePanelConfig = (panelId, panelConfig, breakpoint, { slot, mode, isFullscreen }) => {
|
|
21
|
+
const config = panelConfig[panelId]
|
|
22
|
+
if (!config || isConsumerHtml(config)) {
|
|
23
|
+
return null
|
|
22
24
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
const bpConfig = config[breakpoint]
|
|
26
|
+
if (!bpConfig) {
|
|
27
|
+
return null
|
|
25
28
|
}
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
const targetSlot = resolveTargetSlot(bpConfig, breakpoint)
|
|
30
|
+
if (!isPanelSlotEligible(config, { targetSlot, slot, mode, isFullscreen })) {
|
|
31
|
+
return null
|
|
28
32
|
}
|
|
29
|
-
|
|
30
|
-
return false
|
|
31
|
-
}
|
|
32
|
-
if (bpConfig.modal && panelId !== allowedModalPanelId) {
|
|
33
|
-
return false
|
|
34
|
-
}
|
|
35
|
-
return true
|
|
33
|
+
return { config, bpConfig }
|
|
36
34
|
}
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*/
|
|
36
|
+
// A losing modal panel (see getAllowedModalPanelId) still gets a shell — it's just not open.
|
|
37
|
+
const resolveIsOpen = (openEntry, bpConfig, panelId, allowedModalPanelId) =>
|
|
38
|
+
Boolean(openEntry) && (!bpConfig.modal || panelId === allowedModalPanelId)
|
|
39
|
+
|
|
43
40
|
export function mapPanels ({ slot, appState, evaluateProp }) {
|
|
44
41
|
const { breakpoint, pluginRegistry, panelConfig, mode, openPanels } = appState
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const modalPanels = openPanelEntries.filter(([panelId]) => {
|
|
49
|
-
const cfg = panelConfig[panelId]?.[breakpoint]
|
|
50
|
-
return cfg?.modal
|
|
51
|
-
})
|
|
52
|
-
const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR, .at() is only Chrome 90+
|
|
53
|
-
|
|
54
|
-
return openPanelEntries.map(([panelId, { props, focusOnOpen }]) => {
|
|
55
|
-
const config = panelConfig[panelId]
|
|
56
|
-
if (!config) {
|
|
57
|
-
return null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Consumer HTML panels are managed by HtmlElementHost
|
|
61
|
-
if (isConsumerHtml(config)) {
|
|
62
|
-
return null
|
|
63
|
-
}
|
|
43
|
+
// Only the most-recently-opened modal panel is ever actually shown — see isOpen below.
|
|
44
|
+
const allowedModalPanelId = getAllowedModalPanelId(openPanels, panelConfig, breakpoint)
|
|
64
45
|
|
|
65
|
-
|
|
66
|
-
|
|
46
|
+
return Object.keys(panelConfig).map((panelId) => {
|
|
47
|
+
const eligible = getEligiblePanelConfig(panelId, panelConfig, breakpoint, { slot, mode, isFullscreen: appState.isFullscreen })
|
|
48
|
+
if (!eligible) {
|
|
67
49
|
return null
|
|
68
50
|
}
|
|
51
|
+
const { config, bpConfig } = eligible
|
|
69
52
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
targetSlot, slot, mode, isFullscreen: appState.isFullscreen, allowedModalPanelId
|
|
74
|
-
})) {
|
|
75
|
-
return null
|
|
76
|
-
}
|
|
53
|
+
const openEntry = openPanels[panelId]
|
|
54
|
+
const isOpen = resolveIsOpen(openEntry, bpConfig, panelId, allowedModalPanelId)
|
|
55
|
+
const { props = {}, focusOnOpen } = openEntry ?? {}
|
|
77
56
|
|
|
78
57
|
const plugin = pluginRegistry.registeredPlugins.find(p => p.id === config.pluginId)
|
|
79
58
|
const pluginId = plugin?.id
|
|
@@ -92,7 +71,8 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
|
|
|
92
71
|
panelConfig={config}
|
|
93
72
|
props={props}
|
|
94
73
|
focusOnOpen={focusOnOpen}
|
|
95
|
-
{
|
|
74
|
+
isOpen={isOpen}
|
|
75
|
+
{...(isOpen ? buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) : {})}
|
|
96
76
|
label={label}
|
|
97
77
|
html={html}
|
|
98
78
|
/>
|
|
@@ -40,8 +40,25 @@ describe('mapPanels', () => {
|
|
|
40
40
|
defaultAppState.panelConfig = ({ p1: baseConfig })
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
it('
|
|
44
|
-
|
|
43
|
+
it('renders a closed shell (not nothing) for an eligible panel that is not open', () => {
|
|
44
|
+
const result = map({ ...defaultAppState, openPanels: {} })
|
|
45
|
+
expect(result).toHaveLength(1)
|
|
46
|
+
expect(result[0]).toMatchObject({ id: 'p1', type: 'panel', order: 1 })
|
|
47
|
+
expect(result[0].element.props.isOpen).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('does not build a closed panel\'s body before it opens', () => {
|
|
51
|
+
const renderFn = jest.fn(() => <div>child</div>)
|
|
52
|
+
defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
|
|
53
|
+
const result = map({ ...defaultAppState, openPanels: {} })
|
|
54
|
+
expect(mapControls).not.toHaveBeenCalled()
|
|
55
|
+
expect(withPluginContexts).not.toHaveBeenCalled()
|
|
56
|
+
expect(result[0].element.props.items).toBeUndefined()
|
|
57
|
+
expect(result[0].element.props.tabs).toBeUndefined()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns an empty array only when there is nothing eligible for the slot at all', () => {
|
|
61
|
+
expect(map({ ...defaultAppState, panelConfig: {} })).toEqual([])
|
|
45
62
|
})
|
|
46
63
|
|
|
47
64
|
it('skips panel if config is missing', () => {
|
|
@@ -49,6 +66,11 @@ describe('mapPanels', () => {
|
|
|
49
66
|
expect(map()).toEqual([])
|
|
50
67
|
})
|
|
51
68
|
|
|
69
|
+
it('skips a registered panelId whose config is falsy', () => {
|
|
70
|
+
defaultAppState.panelConfig = ({ p1: null })
|
|
71
|
+
expect(map()).toEqual([])
|
|
72
|
+
})
|
|
73
|
+
|
|
52
74
|
it('skips panel if breakpoint config is missing', () => {
|
|
53
75
|
defaultAppState.panelConfig = ({ p1: {} })
|
|
54
76
|
expect(map()).toEqual([])
|
|
@@ -92,7 +114,7 @@ describe('mapPanels', () => {
|
|
|
92
114
|
expect(result).toEqual([])
|
|
93
115
|
})
|
|
94
116
|
|
|
95
|
-
it('only
|
|
117
|
+
it('renders both modal panels\' shells but only marks the last-opened one as open', () => {
|
|
96
118
|
defaultAppState.panelConfig = ({
|
|
97
119
|
p1: { desktop: { modal: true }, includeModes: ['view'] },
|
|
98
120
|
p2: { desktop: { modal: true }, includeModes: ['view'] }
|
|
@@ -101,7 +123,10 @@ describe('mapPanels', () => {
|
|
|
101
123
|
...defaultAppState,
|
|
102
124
|
openPanels: { p1: { props: {} }, p2: { props: {} } }
|
|
103
125
|
}
|
|
104
|
-
|
|
126
|
+
const result = map(state, 'modal')
|
|
127
|
+
expect(result.map(r => r.id).sort()).toEqual(['p1', 'p2'])
|
|
128
|
+
expect(result.find(r => r.id === 'p1').element.props.isOpen).toBe(false)
|
|
129
|
+
expect(result.find(r => r.id === 'p2').element.props.isOpen).toBe(true)
|
|
105
130
|
})
|
|
106
131
|
|
|
107
132
|
it('wraps render function with plugin context', () => {
|
|
@@ -60,3 +60,36 @@ export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) =>
|
|
|
60
60
|
export const isConsumerHtml = (config) => {
|
|
61
61
|
return typeof config.html === 'string' && !config.pluginId
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a panel is eligible for a target slot (slot type, mode, inline/fullscreen) —
|
|
66
|
+
* independent of open state and modal exclusivity (see getAllowedModalPanelId). `slot`, if
|
|
67
|
+
* given, also requires an exact match to the panel's resolved targetSlot.
|
|
68
|
+
*/
|
|
69
|
+
export const isPanelSlotEligible = (config, { targetSlot, slot, mode, isFullscreen }) => {
|
|
70
|
+
const isNextToButton = targetSlot.endsWith('-button')
|
|
71
|
+
if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
if (!isModeAllowed(config, mode)) {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
if (config.inline === false && !isFullscreen) {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
if (slot !== undefined && targetSlot !== slot) {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The id of the most-recently-opened modal panel — the only one actually allowed to show.
|
|
87
|
+
export const getAllowedModalPanelId = (openPanels, panelConfig, breakpoint) => {
|
|
88
|
+
const openModalPanelIds = Object.keys(openPanels).filter(panelId => panelConfig[panelId]?.[breakpoint]?.modal)
|
|
89
|
+
return openModalPanelIds.length > 0 ? openModalPanelIds[openModalPanelIds.length - 1] : null // NOSONAR, .at() is only Chrome 90+
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Whether any modal-configured panel is open — drives the modal backdrop's visibility.
|
|
93
|
+
export const hasOpenModalPanel = (openPanels, panelConfig, breakpoint) => {
|
|
94
|
+
return getAllowedModalPanelId(openPanels, panelConfig, breakpoint) !== null
|
|
95
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
|
|
1
|
+
import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel } from './slotHelpers.js'
|
|
2
2
|
|
|
3
|
-
jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'] } }))
|
|
3
|
+
jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'], panel: ['header', 'modal', 'left-top'] } }))
|
|
4
4
|
|
|
5
5
|
describe('resolveTargetSlot', () => {
|
|
6
6
|
it('returns modal for modal panels', () => {
|
|
@@ -85,3 +85,80 @@ describe('isConsumerHtml', () => {
|
|
|
85
85
|
expect(isConsumerHtml({ render: () => {} })).toBe(false)
|
|
86
86
|
})
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
describe('isPanelSlotEligible', () => {
|
|
90
|
+
const base = { includeModes: ['view'] }
|
|
91
|
+
const ctx = { targetSlot: 'header', mode: 'view', isFullscreen: false }
|
|
92
|
+
|
|
93
|
+
it('returns true for a panel in an allowed slot', () => {
|
|
94
|
+
expect(isPanelSlotEligible(base, ctx)).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('returns false when the target slot is not an allowed panel slot', () => {
|
|
98
|
+
expect(isPanelSlotEligible(base, { ...ctx, targetSlot: 'invalid' })).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('allows a target slot next to a button even though it is not a named panel slot', () => {
|
|
102
|
+
expect(isPanelSlotEligible(base, { ...ctx, targetSlot: 'my-button-button' })).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns false when mode is not allowed', () => {
|
|
106
|
+
expect(isPanelSlotEligible({ includeModes: ['edit'] }, ctx)).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('returns false when inline:false and not fullscreen', () => {
|
|
110
|
+
expect(isPanelSlotEligible({ ...base, inline: false }, ctx)).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('returns true when inline:false and fullscreen', () => {
|
|
114
|
+
expect(isPanelSlotEligible({ ...base, inline: false }, { ...ctx, isFullscreen: true })).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('returns false when a specific requested slot is given and the target slot does not match it', () => {
|
|
118
|
+
expect(isPanelSlotEligible(base, { ...ctx, slot: 'left-top' })).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('returns true when a specific requested slot is given and matches the target slot', () => {
|
|
122
|
+
expect(isPanelSlotEligible(base, { ...ctx, slot: 'header' })).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('skips the requested-slot check entirely when slot is omitted (HtmlElementHost usage)', () => {
|
|
126
|
+
expect(isPanelSlotEligible(base, { targetSlot: 'header', mode: 'view', isFullscreen: false })).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('getAllowedModalPanelId', () => {
|
|
131
|
+
const panelConfig = {
|
|
132
|
+
p1: { desktop: { modal: true } },
|
|
133
|
+
p2: { desktop: { modal: true } },
|
|
134
|
+
p3: { desktop: {} }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it('returns null when no modal panel is open', () => {
|
|
138
|
+
expect(getAllowedModalPanelId({ p3: { props: {} } }, panelConfig, 'desktop')).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('returns the only open modal panel', () => {
|
|
142
|
+
expect(getAllowedModalPanelId({ p1: { props: {} } }, panelConfig, 'desktop')).toBe('p1')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns the most recently opened modal panel when more than one is open', () => {
|
|
146
|
+
expect(getAllowedModalPanelId({ p1: { props: {} }, p2: { props: {} } }, panelConfig, 'desktop')).toBe('p2')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns null when there are no open panels at all', () => {
|
|
150
|
+
expect(getAllowedModalPanelId({}, panelConfig, 'desktop')).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('hasOpenModalPanel', () => {
|
|
155
|
+
const panelConfig = { p1: { desktop: { modal: true } }, p2: { desktop: {} } }
|
|
156
|
+
|
|
157
|
+
it('returns false when no modal panel is open', () => {
|
|
158
|
+
expect(hasOpenModalPanel({ p2: { props: {} } }, panelConfig, 'desktop')).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('returns true when a modal panel is open', () => {
|
|
162
|
+
expect(hasOpenModalPanel({ p1: { props: {} } }, panelConfig, 'desktop')).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
})
|