@defra/interactive-map 0.0.42-fmp-4 → 0.0.43-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/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/keyboard-shortcut-definition.md +102 -0
- package/docs/api.md +2 -0
- package/docs/building-a-plugin.md +1 -0
- package/docs/plugins/draw.md +1 -1
- package/docs/plugins/plugin-manifest.md +9 -0
- package/package.json +1 -1
- 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/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +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/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/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/src/DrawInit.jsx +23 -13
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +15 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +18 -0
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +9 -0
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +12 -1
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +18 -0
- package/plugins/draw/src/manifest.js +4 -1
- package/plugins/draw/src/manifest.test.js +19 -7
- 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/events.js +6 -2
- package/plugins/interact/src/events.test.js +32 -0
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +8 -3
- package/plugins/interact/src/hooks/useMapItemList.js +41 -12
- package/plugins/interact/src/hooks/useMapItemList.test.js +68 -3
- 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 -1
- 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/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/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/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/src/defaults.test.js +26 -0
- package/src/App/components/CrossHair/CrossHair.jsx +19 -11
- package/src/App/components/CrossHair/CrossHair.module.scss +33 -12
- package/src/App/components/CrossHair/CrossHair.test.jsx +35 -11
- 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.test.jsx +42 -4
- 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/Features.jsx +18 -4
- package/src/App/components/Viewport/Features.module.scss +4 -4
- package/src/App/components/Viewport/Features.test.jsx +155 -11
- package/src/App/components/Viewport/Viewport.jsx +3 -3
- 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/useFeatureFocus.js +86 -28
- package/src/App/hooks/useFeatureFocus.test.js +167 -0
- package/src/App/hooks/useFeatureItems.js +1 -1
- 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/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/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/InteractiveMap/InteractiveMap.js +13 -2
- package/src/InteractiveMap/InteractiveMap.test.js +33 -0
- package/src/config/appConfig.js +2 -2
- package/src/types.js +40 -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
|
@@ -154,6 +154,14 @@ describe('useFeatureFocus — onBlur', () => {
|
|
|
154
154
|
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
|
|
155
155
|
expect(() => act(() => result.current.onBlur())).not.toThrow()
|
|
156
156
|
})
|
|
157
|
+
|
|
158
|
+
// The "moving between sibling options shouldn't count as a real blur/focus" behavior is
|
|
159
|
+
// driven by an internal isInternalFocusMoveRef, set only while focusOption()'s own .focus()
|
|
160
|
+
// call is synchronously in flight — not observable by calling onFocus/onBlur directly here,
|
|
161
|
+
// since that requires a real DOM focus transition bubbling through React. See the
|
|
162
|
+
// "Features + useFeatureFocus — roving tabindex real focus events" suite in Features.test.jsx,
|
|
163
|
+
// which renders the real markup and is the only level this can be faithfully exercised at
|
|
164
|
+
// (and is where the regression this guards against was actually found).
|
|
157
165
|
})
|
|
158
166
|
|
|
159
167
|
// ─── useFeatureFocus — keydown listener lifecycle ────────────────────────────
|
|
@@ -348,6 +356,70 @@ describe('useFeatureFocus — ArrowUp navigation', () => {
|
|
|
348
356
|
})
|
|
349
357
|
})
|
|
350
358
|
|
|
359
|
+
// ─── useFeatureFocus — Home/End navigation ───────────────────────────────────
|
|
360
|
+
|
|
361
|
+
describe('useFeatureFocus — Home/End navigation', () => {
|
|
362
|
+
const setup = () => {
|
|
363
|
+
const refs = makeRefs()
|
|
364
|
+
const el = refs.featuresRef.current
|
|
365
|
+
document.body.appendChild(el)
|
|
366
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
367
|
+
return { result, el, unmount }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
it('jumps to the first item on Home from anywhere in the list', () => {
|
|
371
|
+
const { result, el, unmount } = setup()
|
|
372
|
+
act(() => result.current.onFocus())
|
|
373
|
+
fireKey(el, 'ArrowDown')
|
|
374
|
+
fireKey(el, 'Home')
|
|
375
|
+
expect(result.current.activeFeatureId).toBe('a')
|
|
376
|
+
unmount(); el.remove()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('jumps to the last item on End from anywhere in the list', () => {
|
|
380
|
+
const { result, el, unmount } = setup()
|
|
381
|
+
act(() => result.current.onFocus())
|
|
382
|
+
fireKey(el, 'End')
|
|
383
|
+
expect(result.current.activeFeatureId).toBe('c')
|
|
384
|
+
unmount(); el.remove()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('does nothing when items is empty', () => {
|
|
388
|
+
const refs = makeRefs()
|
|
389
|
+
const el = refs.featuresRef.current
|
|
390
|
+
document.body.appendChild(el)
|
|
391
|
+
const { result, unmount } = renderHook(() => useFeatureFocus(refs))
|
|
392
|
+
fireKey(el, 'Home')
|
|
393
|
+
fireKey(el, 'End')
|
|
394
|
+
expect(result.current.activeFeatureId).toBeNull()
|
|
395
|
+
unmount(); el.remove()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('stops propagation on Home/End so the viewport keyboard handler does not also handle it', () => {
|
|
399
|
+
const { el, unmount } = setup()
|
|
400
|
+
for (const key of ['Home', 'End']) {
|
|
401
|
+
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
|
|
402
|
+
const stopSpy = jest.spyOn(event, 'stopPropagation')
|
|
403
|
+
act(() => el.dispatchEvent(event))
|
|
404
|
+
expect(stopSpy).toHaveBeenCalled()
|
|
405
|
+
}
|
|
406
|
+
unmount()
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('emits map:setactivefeature with the boundary id', () => {
|
|
410
|
+
const eb = makeEventBus()
|
|
411
|
+
const refs = makeRefs()
|
|
412
|
+
const el = refs.featuresRef.current
|
|
413
|
+
document.body.appendChild(el)
|
|
414
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
|
|
415
|
+
act(() => result.current.onFocus())
|
|
416
|
+
eb.emit.mockClear()
|
|
417
|
+
fireKey(el, 'End')
|
|
418
|
+
expect(eb.emit).toHaveBeenCalledWith(SET_ACTIVE, { id: 'c' })
|
|
419
|
+
unmount(); el.remove()
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
351
423
|
// ─── useFeatureFocus — eventBus integration ───────────────────────────────────
|
|
352
424
|
|
|
353
425
|
describe('useFeatureFocus — eventBus: map:setactivefeature listener', () => {
|
|
@@ -633,3 +705,98 @@ describe('useFeatureFocus — items change while focused', () => {
|
|
|
633
705
|
unmount()
|
|
634
706
|
})
|
|
635
707
|
})
|
|
708
|
+
|
|
709
|
+
// ─── useFeatureFocus — tabbableId (roving tabindex resting position) ─────────
|
|
710
|
+
|
|
711
|
+
describe('useFeatureFocus — tabbableId', () => {
|
|
712
|
+
it('is null when items is empty', () => {
|
|
713
|
+
const { result } = renderHook(() => useFeatureFocus(makeRefs()))
|
|
714
|
+
expect(result.current.tabbableId).toBeNull()
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it('defaults to the first item before the list has ever been focused', () => {
|
|
718
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
|
|
719
|
+
expect(result.current.tabbableId).toBe('a')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
// Unlike onFocus's own resolution, tabbableId deliberately ignores selection — "prefer the
|
|
723
|
+
// selected item" is a real-Tab-in-only concern. If tabbableId also preferred it, selecting a
|
|
724
|
+
// single marker by clicking it on the map (which never touches lastActiveIdRef) would relocate
|
|
725
|
+
// tabIndex to match it on every click.
|
|
726
|
+
it('is not influenced by a selection change before any position is established', () => {
|
|
727
|
+
const eb = makeEventBus()
|
|
728
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
729
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedFeatures: [{ featureId: 'b', layerId: 'roads' }] }))
|
|
730
|
+
expect(result.current.tabbableId).toBe('a')
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it('does not chase a single item toggled selected/deselected purely via mouse clicks, with no keyboard interaction at all', () => {
|
|
734
|
+
const eb = makeEventBus()
|
|
735
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
736
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['a'] }))
|
|
737
|
+
expect(result.current.tabbableId).toBe('a')
|
|
738
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['b'] })) // clicked a different marker
|
|
739
|
+
expect(result.current.tabbableId).toBe('a') // still the structural first item — never moved
|
|
740
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['c'] }))
|
|
741
|
+
expect(result.current.tabbableId).toBe('a')
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('stays on the established position when a selection change arrives from elsewhere (e.g. clicking a marker on the map)', () => {
|
|
745
|
+
const eb = makeEventBus()
|
|
746
|
+
const refs = makeRefs()
|
|
747
|
+
const el = refs.featuresRef.current
|
|
748
|
+
document.body.appendChild(el)
|
|
749
|
+
const { result, unmount } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS, eventBus: eb }))
|
|
750
|
+
act(() => result.current.onFocus()) // establishes position at 'a'
|
|
751
|
+
fireKey(el, 'ArrowDown') // establishes position at 'b'
|
|
752
|
+
act(() => result.current.onBlur())
|
|
753
|
+
// A selection made outside the list (e.g. a mouse click on a map marker) never touches
|
|
754
|
+
// lastActiveIdRef — it only changes selectedIds via interact:selectionchange.
|
|
755
|
+
act(() => eb.trigger(SELECTION_CHANGE, { selectedMarkers: ['c'] }))
|
|
756
|
+
expect(result.current.tabbableId).toBe('b') // stays put — does not jump to the newly-selected 'c'
|
|
757
|
+
unmount(); el.remove()
|
|
758
|
+
})
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
// ─── useFeatureFocus — selectItem (click / touch / voice-control activation) ─
|
|
762
|
+
|
|
763
|
+
describe('useFeatureFocus — selectItem', () => {
|
|
764
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
765
|
+
|
|
766
|
+
it('sets activeFeatureId to the clicked item', () => {
|
|
767
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
|
|
768
|
+
act(() => result.current.selectItem('b'))
|
|
769
|
+
expect(result.current.activeFeatureId).toBe('b')
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('emits map:setactivefeature then map:selectfeature for the clicked item', () => {
|
|
773
|
+
const eb = makeEventBus()
|
|
774
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS, eventBus: eb }))
|
|
775
|
+
act(() => result.current.selectItem('b'))
|
|
776
|
+
expect(eb.emit).toHaveBeenNthCalledWith(1, SET_ACTIVE, { id: 'b' })
|
|
777
|
+
expect(eb.emit).toHaveBeenNthCalledWith(2, SELECT)
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
it('moves real DOM focus to the clicked option', () => {
|
|
781
|
+
const refs = makeRefs()
|
|
782
|
+
const option = document.createElement('li')
|
|
783
|
+
option.dataset.id = 'b'
|
|
784
|
+
option.focus = jest.fn()
|
|
785
|
+
refs.featuresRef.current.appendChild(option)
|
|
786
|
+
const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
787
|
+
act(() => result.current.selectItem('b'))
|
|
788
|
+
expect(option.focus).toHaveBeenCalledWith({ preventScroll: true })
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it('does not throw when eventBus is not provided', () => {
|
|
792
|
+
const { result } = renderHook(() => useFeatureFocus({ ...makeRefs(), items: ITEMS }))
|
|
793
|
+
expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('does not throw when featuresRef.current is null (e.g. unmounted)', () => {
|
|
797
|
+
const refs = { ...makeRefs(), featuresRef: { current: null } }
|
|
798
|
+
const { result } = renderHook(() => useFeatureFocus({ ...refs, items: ITEMS }))
|
|
799
|
+
expect(() => act(() => result.current.selectItem('b'))).not.toThrow()
|
|
800
|
+
expect(result.current.activeFeatureId).toBe('b') // state still updates even though there's no element to focus
|
|
801
|
+
})
|
|
802
|
+
})
|
|
@@ -9,7 +9,7 @@ const SET_FEATURES_SUPPRESSED = 'map:setfeaturessuppressed'
|
|
|
9
9
|
* actually visible — which is what drives <Features>'s own tabIndex/aria-hidden.
|
|
10
10
|
*
|
|
11
11
|
* @param {object} eventBus
|
|
12
|
-
* @returns {{ items: Array<{ id: string, label: string }>, multiselectable: boolean }}
|
|
12
|
+
* @returns {{ items: Array<{ id: string, label: string, x?: number, y?: number }>, multiselectable: boolean }}
|
|
13
13
|
*/
|
|
14
14
|
export function useFeatureItems (eventBus) {
|
|
15
15
|
const [items, setItems] = useState([])
|
|
@@ -58,7 +58,8 @@ export const useInterfaceAPI = () => {
|
|
|
58
58
|
const handleAppClosed = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
|
|
59
59
|
const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
|
|
60
60
|
const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
|
|
61
|
-
const handleShowPanel = ({ id, focus = true }) =>
|
|
61
|
+
const handleShowPanel = ({ id, focus = true, triggeringElement }) =>
|
|
62
|
+
dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id, focusOnOpen: focus, props: triggeringElement ? { triggeringElement } : undefined } })
|
|
62
63
|
const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
|
|
63
64
|
const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
|
|
64
65
|
|
|
@@ -92,6 +92,15 @@ describe('useInterfaceAPI', () => {
|
|
|
92
92
|
expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: false } })
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
+
it('dispatches OPEN_PANEL with props.triggeringElement when one is given, so Panel can return focus to it on close', () => {
|
|
96
|
+
const triggeringElement = document.createElement('button')
|
|
97
|
+
renderHook(() => useInterfaceAPI())
|
|
98
|
+
act(() => mockEventBus.emit('app:showpanel', { id: 'panel1', triggeringElement }))
|
|
99
|
+
expect(mockDispatch).toHaveBeenCalledWith({
|
|
100
|
+
type: 'OPEN_PANEL', payload: { panelId: 'panel1', focusOnOpen: true, props: { triggeringElement } }
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
95
104
|
it('dispatches CLOSE_PANEL on app:hidepanel', () => {
|
|
96
105
|
renderHook(() => useInterfaceAPI())
|
|
97
106
|
act(() => mockEventBus.emit('app:hidepanel', 'panel1'))
|
|
@@ -5,6 +5,71 @@ import { useConfig } from '../store/configContext.js'
|
|
|
5
5
|
import { useApp } from '../store/appContext.js'
|
|
6
6
|
import { useService } from '../store/serviceContext.js'
|
|
7
7
|
|
|
8
|
+
const normalizeKey = (e) => {
|
|
9
|
+
let key
|
|
10
|
+
|
|
11
|
+
// Use e.code for letters to avoid 'dead' keys with Alt/AltGr
|
|
12
|
+
if (/^Key[A-Z]$/.test(e.code)) {
|
|
13
|
+
key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
|
|
14
|
+
} else {
|
|
15
|
+
key = e.key // works for arrows, numpad, punctuation
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Normalize numpad add/subtract to symbols
|
|
19
|
+
if (key === 'Add' || key === 'NumpadAdd') {
|
|
20
|
+
key = '+'
|
|
21
|
+
} else if (key === 'Subtract' || key === 'NumpadSubtract') {
|
|
22
|
+
key = '-'
|
|
23
|
+
} else {
|
|
24
|
+
// No action
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check altKey first: AltGr (used on many non-US keyboards) reports both altKey
|
|
28
|
+
// and ctrlKey as true, and should still resolve to the Alt+ binding, not Ctrl+.
|
|
29
|
+
if (e.altKey) {
|
|
30
|
+
return `Alt+${key}`
|
|
31
|
+
}
|
|
32
|
+
if (e.ctrlKey) {
|
|
33
|
+
return `Ctrl+${key}`
|
|
34
|
+
}
|
|
35
|
+
return key
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const resolveAction = (actions, type, key) => {
|
|
39
|
+
const actionName = keyboardMappings[type][key]
|
|
40
|
+
return actionName && actions[actionName] ? actionName : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// keydown (pan/zoom) stays on the viewport so arrows only fire when the map has focus.
|
|
44
|
+
// keyup (Alt+K and other global shortcuts) attaches to the app container so it fires
|
|
45
|
+
// from anywhere within the app, including the features listbox.
|
|
46
|
+
const createKeyEventHandlers = (actions) => {
|
|
47
|
+
const handleKeyDown = (e) => {
|
|
48
|
+
const key = normalizeKey(e)
|
|
49
|
+
const actionName = resolveAction(actions, 'keydown', key)
|
|
50
|
+
if (actionName) {
|
|
51
|
+
actions[actionName](e)
|
|
52
|
+
e.preventDefault()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
// A keyup-bound shortcut (e.g. Alt+Arrow label navigation) still acts on release,
|
|
56
|
+
// but its browser default (e.g. arrow-key scroll) must be suppressed now or it's too late.
|
|
57
|
+
if (resolveAction(actions, 'keyup', key)) {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const handleKeyUp = (e) => {
|
|
63
|
+
const actionName = resolveAction(actions, 'keyup', normalizeKey(e))
|
|
64
|
+
if (actionName) {
|
|
65
|
+
actions[actionName](e)
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { handleKeyDown, handleKeyUp }
|
|
71
|
+
}
|
|
72
|
+
|
|
8
73
|
export function useKeyboardShortcuts (containerRef) {
|
|
9
74
|
const { mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta, readMapText } = useConfig()
|
|
10
75
|
const { dispatch, layoutRefs } = useApp()
|
|
@@ -27,42 +92,8 @@ export function useKeyboardShortcuts (containerRef) {
|
|
|
27
92
|
readMapText
|
|
28
93
|
})
|
|
29
94
|
|
|
30
|
-
const
|
|
31
|
-
let key
|
|
32
|
-
|
|
33
|
-
// Use e.code for letters to avoid 'dead' keys with Alt/AltGr
|
|
34
|
-
if (/^Key[A-Z]$/.test(e.code)) {
|
|
35
|
-
key = e.code.slice(3) // NOSONAR: strip "Key" prefix, e.g. "KeyI" -> "I"
|
|
36
|
-
} else {
|
|
37
|
-
key = e.key // works for arrows, numpad, punctuation
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Normalize numpad add/subtract to symbols
|
|
41
|
-
if (key === 'Add' || key === 'NumpadAdd') {
|
|
42
|
-
key = '+'
|
|
43
|
-
} else if (key === 'Subtract' || key === 'NumpadSubtract') {
|
|
44
|
-
key = '-'
|
|
45
|
-
} else {
|
|
46
|
-
// No action
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return e.altKey ? `Alt+${key}` : key
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const handle = (type) => (e) => {
|
|
53
|
-
const actionName = keyboardMappings[type][normalizeKey(e)]
|
|
54
|
-
if (actionName && actions[actionName]) {
|
|
55
|
-
actions[actionName](e)
|
|
56
|
-
e.preventDefault()
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const handleKeyDown = handle('keydown')
|
|
61
|
-
const handleKeyUp = handle('keyup')
|
|
95
|
+
const { handleKeyDown, handleKeyUp } = createKeyEventHandlers(actions)
|
|
62
96
|
|
|
63
|
-
// keydown (pan/zoom) stays on the viewport so arrows only fire when the map has focus.
|
|
64
|
-
// keyup (Alt+K and other global shortcuts) attaches to the app container so it fires
|
|
65
|
-
// from anywhere within the app, including the features listbox.
|
|
66
97
|
el.addEventListener('keydown', handleKeyDown)
|
|
67
98
|
appEl.addEventListener('keyup', handleKeyUp)
|
|
68
99
|
|
|
@@ -138,6 +138,58 @@ describe('useKeyboardShortcuts', () => {
|
|
|
138
138
|
expect(actions.stopPan).toHaveBeenCalled()
|
|
139
139
|
})
|
|
140
140
|
|
|
141
|
+
test('does nothing on keyup when the key has no mapped action', () => {
|
|
142
|
+
const { appContainerRef, actions } = setup({ keyup: { ArrowUp: 'stopPan' }, actions: { stopPan: jest.fn() } })
|
|
143
|
+
renderHook(() => useKeyboardShortcuts({ current: document.createElement('div') }))
|
|
144
|
+
|
|
145
|
+
const evt = new KeyboardEvent('keyup', { key: 'Z' })
|
|
146
|
+
const spy = jest.spyOn(evt, 'preventDefault')
|
|
147
|
+
appContainerRef.current.dispatchEvent(evt)
|
|
148
|
+
|
|
149
|
+
expect(spy).not.toHaveBeenCalled()
|
|
150
|
+
expect(actions.stopPan).not.toHaveBeenCalled()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('suppresses the browser default on keydown for a keyup-bound shortcut, without firing the action early', () => {
|
|
154
|
+
const { containerRef, appContainerRef, actions } = setup({
|
|
155
|
+
keyup: { 'Alt+ArrowUp': 'highlightNextLabel' },
|
|
156
|
+
actions: { highlightNextLabel: jest.fn() }
|
|
157
|
+
})
|
|
158
|
+
renderHook(() => useKeyboardShortcuts(containerRef))
|
|
159
|
+
|
|
160
|
+
const keydownEvt = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true })
|
|
161
|
+
const keydownSpy = jest.spyOn(keydownEvt, 'preventDefault')
|
|
162
|
+
containerRef.current.dispatchEvent(keydownEvt)
|
|
163
|
+
expect(keydownSpy).toHaveBeenCalled()
|
|
164
|
+
expect(actions.highlightNextLabel).not.toHaveBeenCalled()
|
|
165
|
+
|
|
166
|
+
appContainerRef.current.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp', altKey: true }))
|
|
167
|
+
expect(actions.highlightNextLabel).toHaveBeenCalled()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('normalizes Ctrl+key combinations', () => {
|
|
171
|
+
const { containerRef, actions } = setup({
|
|
172
|
+
keydown: { 'Ctrl+I': 'getInfo' },
|
|
173
|
+
actions: { getInfo: jest.fn() }
|
|
174
|
+
})
|
|
175
|
+
renderHook(() => useKeyboardShortcuts(containerRef))
|
|
176
|
+
|
|
177
|
+
containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyI', key: 'i', ctrlKey: true }))
|
|
178
|
+
expect(actions.getInfo).toHaveBeenCalled()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('treats AltGr (altKey + ctrlKey both true) as Alt+, not Ctrl+', () => {
|
|
182
|
+
const { containerRef, actions } = setup({
|
|
183
|
+
keydown: { 'Alt+I': 'getInfo', 'Ctrl+I': 'zoomIn' },
|
|
184
|
+
actions: { getInfo: jest.fn(), zoomIn: jest.fn() }
|
|
185
|
+
})
|
|
186
|
+
renderHook(() => useKeyboardShortcuts(containerRef))
|
|
187
|
+
|
|
188
|
+
containerRef.current.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyI', key: 'i', altKey: true, ctrlKey: true }))
|
|
189
|
+
expect(actions.getInfo).toHaveBeenCalled()
|
|
190
|
+
expect(actions.zoomIn).not.toHaveBeenCalled()
|
|
191
|
+
})
|
|
192
|
+
|
|
141
193
|
test('prevents default when action exists, does nothing otherwise', () => {
|
|
142
194
|
const { containerRef } = setup({ keydown: { I: 'zoomIn', X: 'nonExistent' } })
|
|
143
195
|
renderHook(() => useKeyboardShortcuts(containerRef))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect } from 'react'
|
|
1
|
+
import { useEffect, useLayoutEffect } from 'react'
|
|
2
2
|
import { useResizeObserver } from './useResizeObserver.js'
|
|
3
3
|
import { constrainKeyboardFocus } from '../../utils/constrainKeyboardFocus.js'
|
|
4
4
|
import { toggleInertElements } from '../../utils/toggleInertElements.js'
|
|
@@ -62,6 +62,44 @@ const setSlotCSSVar = (slot, layoutRefs, primaryMargin) => {
|
|
|
62
62
|
root.style.setProperty(MODAL_MAX_HEIGHT, `${mainRect.height - relTop - primaryMargin}px`)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Computes and applies --modal-inset/--modal-max-height for the panel's current slot. Shared by
|
|
66
|
+
// the resize observer (keeps position correct across later layout changes while open) and the
|
|
67
|
+
// open-triggered layout effect below (panels stay permanently mounted — see mapPanels.js — so
|
|
68
|
+
// opening one doesn't itself resize mainRef and wouldn't otherwise trigger a recalc).
|
|
69
|
+
const recalcModalPosition = ({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs }) => {
|
|
70
|
+
if (!isModal || !mainRef.current) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const root = document.documentElement
|
|
75
|
+
const styles = getComputedStyle(root)
|
|
76
|
+
const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
|
|
77
|
+
const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
|
|
78
|
+
const slot = panelRef.current.dataset.slot
|
|
79
|
+
|
|
80
|
+
// Button-adjacent panels: position next to the controlling button.
|
|
81
|
+
// Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
|
|
82
|
+
// when there is no triggeringElement (e.g. panel opened programmatically).
|
|
83
|
+
// Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
|
|
84
|
+
if (slot?.endsWith('-button')) {
|
|
85
|
+
const panelElId = panelRef.current?.id
|
|
86
|
+
const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
|
|
87
|
+
const effectiveContainer = currentButtonEl?.parentElement ??
|
|
88
|
+
(buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
|
|
89
|
+
document.querySelector(`[data-button-slot="${slot}"]`)
|
|
90
|
+
|
|
91
|
+
if (!effectiveContainer) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Slot-based panels: derive position from the slot container element
|
|
100
|
+
setSlotCSSVar(slot, layoutRefs, primaryMargin)
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
const useModalKeyHandler = (panelRef, isModal, handleClose) => {
|
|
66
104
|
useEffect(() => {
|
|
67
105
|
if (!isModal) {
|
|
@@ -131,40 +169,16 @@ export function useModalPanelBehaviour ({
|
|
|
131
169
|
|
|
132
170
|
useModalKeyHandler(panelRef, isModal, handleClose)
|
|
133
171
|
|
|
134
|
-
// ===
|
|
135
|
-
useResizeObserver([mainRef], () => {
|
|
136
|
-
if (!isModal || !mainRef.current) {
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const root = document.documentElement
|
|
141
|
-
const styles = getComputedStyle(root)
|
|
142
|
-
const dividerGap = Number.parseInt(styles.getPropertyValue('--divider-gap'), 10)
|
|
143
|
-
const primaryMargin = Number.parseInt(styles.getPropertyValue('--primary-gap'), 10)
|
|
144
|
-
const slot = panelRef.current.dataset.slot
|
|
145
|
-
|
|
146
|
-
// Button-adjacent panels: position next to the controlling button.
|
|
147
|
-
// Use slot name (not buttonContainerEl) as the gate — buttonContainerEl may be undefined
|
|
148
|
-
// when there is no triggeringElement (e.g. panel opened programmatically).
|
|
149
|
-
// Dynamically query via aria-controls to handle stale triggeringElement after breakpoint changes.
|
|
150
|
-
if (slot?.endsWith('-button')) {
|
|
151
|
-
const panelElId = panelRef.current?.id
|
|
152
|
-
const currentButtonEl = panelElId ? document.querySelector(`[aria-controls="${panelElId}"]`) : null
|
|
153
|
-
const effectiveContainer = currentButtonEl?.parentElement ??
|
|
154
|
-
(buttonContainerEl?.isConnected ? buttonContainerEl : null) ??
|
|
155
|
-
document.querySelector(`[data-button-slot="${slot}"]`)
|
|
156
|
-
|
|
157
|
-
if (!effectiveContainer) {
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
setButtonCSSVar(effectiveContainer, mainRef, dividerGap)
|
|
162
|
-
return
|
|
163
|
-
}
|
|
172
|
+
// === Recalculate on mainRef resize (keeps position correct while already open) === //
|
|
173
|
+
useResizeObserver([mainRef], () => recalcModalPosition({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs }))
|
|
164
174
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
175
|
+
// === Recalculate the moment the panel opens === //
|
|
176
|
+
// The resize observer above won't fire on its own here: its size cache persists across
|
|
177
|
+
// opens/closes (the panel no longer mounts fresh each time), and opening a panel doesn't
|
|
178
|
+
// itself resize mainRef. This is what actually positions the panel at open time.
|
|
179
|
+
useLayoutEffect(() => {
|
|
180
|
+
recalcModalPosition({ isModal, mainRef, panelRef, buttonContainerEl, layoutRefs })
|
|
181
|
+
}, [isModal, mainRef, panelRef, buttonContainerEl, layoutRefs])
|
|
168
182
|
|
|
169
183
|
// === Click on modal backdrop to close === //
|
|
170
184
|
useEffect(() => {
|
|
@@ -218,6 +218,65 @@ describe('useModalPanelBehaviour', () => {
|
|
|
218
218
|
})
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
+
// Regression: panels are mounted permanently and only toggle `hidden` (see mapPanels.js), so
|
|
222
|
+
// opening one doesn't itself resize mainRef. This uses the *real* useResizeObserver (backed by
|
|
223
|
+
// a fake ResizeObserver, not the blanket `(_, cb) => cb()` mock used above) so its size-change
|
|
224
|
+
// dedupe is actually exercised — that dedupe is what silently swallowed the reposition on open.
|
|
225
|
+
describe('repositioning on open when mainRef never resizes', () => {
|
|
226
|
+
const buttonSlot = 'map-styles-button'
|
|
227
|
+
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
const { useResizeObserver: actualUseResizeObserver } = jest.requireActual('./useResizeObserver.js')
|
|
230
|
+
useResizeObserverModule.useResizeObserver.mockImplementation(actualUseResizeObserver)
|
|
231
|
+
|
|
232
|
+
// Real ResizeObserver always delivers once per observe() call — mirror that, and let the
|
|
233
|
+
// real hook's own prevSizes dedupe decide whether the wrapped callback actually runs.
|
|
234
|
+
global.ResizeObserver = jest.fn(function (cb) {
|
|
235
|
+
this.observe = (target) => {
|
|
236
|
+
const entries = [{ target, contentRect: { width: 100, height: 50 } }]
|
|
237
|
+
cb(entries)
|
|
238
|
+
}
|
|
239
|
+
this.disconnect = jest.fn()
|
|
240
|
+
})
|
|
241
|
+
jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 0 })
|
|
242
|
+
jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
243
|
+
jest.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ getPropertyValue: () => '8' })
|
|
244
|
+
|
|
245
|
+
Object.defineProperty(refs.main.current, 'getBoundingClientRect', {
|
|
246
|
+
value: () => ({ top: 0, right: 100, bottom: 50, left: 0, width: 100, height: 50 }),
|
|
247
|
+
configurable: true
|
|
248
|
+
})
|
|
249
|
+
Object.defineProperty(elements.buttonContainer, 'getBoundingClientRect', {
|
|
250
|
+
value: () => ({ top: 10, right: 80, bottom: 40, left: 20, width: 60, height: 30 }),
|
|
251
|
+
configurable: true
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
refs.panel.current.dataset.slot = buttonSlot
|
|
255
|
+
const button = document.createElement('button')
|
|
256
|
+
button.setAttribute(ARIA_CONTROLS, PANEL_ID)
|
|
257
|
+
elements.buttonContainer.appendChild(button)
|
|
258
|
+
document.body.appendChild(elements.buttonContainer)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
afterEach(() => {
|
|
262
|
+
jest.restoreAllMocks()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('positions the panel the moment it opens, not just when mainRef resizes', () => {
|
|
266
|
+
// Mounted closed, as it would be permanently — mainRef's size gets recorded but the
|
|
267
|
+
// calc no-ops on the isModal guard.
|
|
268
|
+
const { rerender } = render(<TestComponent isModal={false} />)
|
|
269
|
+
expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toBe('')
|
|
270
|
+
|
|
271
|
+
// Open it. mainRef hasn't resized, so the resize observer's own delivery is deduped
|
|
272
|
+
// away — only the open-triggered layout effect can position it here.
|
|
273
|
+
rerender(<TestComponent isModal />)
|
|
274
|
+
|
|
275
|
+
expect(document.documentElement.style.getPropertyValue(MODAL_INSET)).toContain('10px')
|
|
276
|
+
expect(document.documentElement.style.getPropertyValue(MODAL_MAX_HEIGHT)).toContain('px')
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
221
280
|
describe('focus management', () => {
|
|
222
281
|
it('redirects focus into panel when focus enters app but outside panel', () => {
|
|
223
282
|
refs.panel.current.focus = jest.fn()
|
|
@@ -11,14 +11,16 @@ import { layoutSlots } from '../renderer/slots'
|
|
|
11
11
|
import { SlotRenderer } from '../renderer/SlotRenderer'
|
|
12
12
|
import { HtmlElementHost } from '../renderer/HtmlElementHost'
|
|
13
13
|
import { Hints } from '../components/Hints/Hints.jsx'
|
|
14
|
+
import { hasOpenModalPanel } from '../renderer/slotHelpers.js'
|
|
14
15
|
import { getMapThemeVars } from '../../config/mapTheme.js'
|
|
15
16
|
|
|
16
17
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
17
18
|
// sonarjs/disable-next-line function-name
|
|
18
19
|
export const Layout = () => {
|
|
19
20
|
const { id, mapLabel, mapHintText } = useConfig()
|
|
20
|
-
const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen } = useApp()
|
|
21
|
+
const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen, openPanels, panelConfig } = useApp()
|
|
21
22
|
const { mapStyle } = useMap()
|
|
23
|
+
const showModalBackdrop = hasOpenModalPanel(openPanels ?? {}, panelConfig ?? {}, breakpoint)
|
|
22
24
|
|
|
23
25
|
useLayoutMeasurements()
|
|
24
26
|
useFocusVisible()
|
|
@@ -108,7 +110,7 @@ export const Layout = () => {
|
|
|
108
110
|
</div>
|
|
109
111
|
<div className='im-o-app__modal' ref={layoutRefs.modalRef}>
|
|
110
112
|
<SlotRenderer slot={layoutSlots.MODAL} />
|
|
111
|
-
<div className='im-o-app__modal-backdrop' />
|
|
113
|
+
<div className={`im-o-app__modal-backdrop${showModalBackdrop ? ' im-o-app__modal-backdrop--visible' : ''}`} />
|
|
112
114
|
</div>
|
|
113
115
|
</div>
|
|
114
116
|
</div>
|
|
@@ -95,6 +95,27 @@ describe('Layout', () => {
|
|
|
95
95
|
expect(screen.getByTestId('slot-top-left')).toBeInTheDocument()
|
|
96
96
|
expect(screen.getByTestId('slot-bottom-right')).toBeInTheDocument()
|
|
97
97
|
expect(screen.getByTestId('slot-modal')).toBeInTheDocument()
|
|
98
|
+
|
|
99
|
+
const backdrop = root.querySelector('.im-o-app__modal-backdrop')
|
|
100
|
+
expect(backdrop).toBeInTheDocument()
|
|
101
|
+
expect(backdrop).not.toHaveClass('im-o-app__modal-backdrop--visible')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('shows the modal backdrop only while a modal-configured panel is open', () => {
|
|
105
|
+
useApp.mockReturnValue({
|
|
106
|
+
breakpoint: 'mobile',
|
|
107
|
+
interfaceType: 'map',
|
|
108
|
+
preferredColorScheme: 'dark',
|
|
109
|
+
layoutRefs: mockRefs,
|
|
110
|
+
isLayoutReady: true,
|
|
111
|
+
hasExclusiveControl: false,
|
|
112
|
+
isFullscreen: true,
|
|
113
|
+
openPanels: { settings: { props: {} } },
|
|
114
|
+
panelConfig: { settings: { mobile: { modal: true } } }
|
|
115
|
+
})
|
|
116
|
+
render(<Layout />)
|
|
117
|
+
const root = document.getElementById('myApp-im-app')
|
|
118
|
+
expect(root.querySelector('.im-o-app__modal-backdrop')).toHaveClass('im-o-app__modal-backdrop--visible')
|
|
98
119
|
})
|
|
99
120
|
|
|
100
121
|
test('applies "not-ready" class when layout is not ready', () => {
|
|
@@ -363,7 +363,13 @@
|
|
|
363
363
|
z-index: 1000;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
|
|
366
|
+
// Visibility is JS-driven (Layout.jsx) since multiple panels can share this slot.
|
|
367
|
+
.im-o-app__modal-backdrop {
|
|
368
|
+
display: none;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.im-o-app__modal-backdrop--visible {
|
|
372
|
+
display: block;
|
|
367
373
|
position: absolute;
|
|
368
374
|
top: 0;
|
|
369
375
|
left: 0;
|
|
@@ -19,8 +19,8 @@ const _setProviderSupportedShortcuts = (state, ids = []) => {
|
|
|
19
19
|
|
|
20
20
|
const _getKeyboardShortcuts = (state, appConfig = {}) => {
|
|
21
21
|
const filteredCore = coreShortcuts.filter(s => {
|
|
22
|
-
// Must be supported by provider
|
|
23
|
-
if (!state.providerSupportedIds.has(s.id)) {
|
|
22
|
+
// Must be supported by the map provider, unless the shortcut is map-provider-agnostic (no capability dependency, so no adapter needs to declare it).
|
|
23
|
+
if (!s.mapProviderAgnostic && !state.providerSupportedIds.has(s.id)) {
|
|
24
24
|
return false
|
|
25
25
|
}
|
|
26
26
|
// Check requiredConfig - all specified config values must be truthy
|