@defra/interactive-map 0.0.4-alpha → 0.0.5-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/index.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api.md +8 -0
- package/package.json +3 -2
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
- package/plugins/beta/datasets/dist/umd/index.js +1 -1
- package/plugins/beta/datasets/src/datasets.js +55 -2
- package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
- package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
- package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
- package/plugins/beta/datasets/src/mapLayers.js +41 -3
- package/plugins/beta/datasets/src/utils/bbox.js +113 -2
- package/plugins/beta/draw-ml/dist/css/index.css +1 -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-ml/dist/umd/index.js +1 -1
- package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
- package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
- package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
- package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
- package/plugins/beta/draw-ml/src/defaults.js +1 -0
- package/plugins/beta/draw-ml/src/draw.scss +23 -0
- package/plugins/beta/draw-ml/src/events.js +55 -113
- package/plugins/beta/draw-ml/src/manifest.js +64 -38
- package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
- package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
- package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
- package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
- package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
- package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
- 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 +1 -1
- package/plugins/beta/map-styles/dist/css/index.css +1 -1
- package/plugins/beta/map-styles/dist/esm/index.js +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/map-styles/src/mapStyles.scss +4 -1
- package/plugins/interact/dist/css/index.css +1 -1
- 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 +1 -1
- package/plugins/interact/src/defaults.js +1 -0
- package/plugins/interact/src/events.js +37 -52
- package/plugins/interact/src/events.test.js +35 -0
- package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
- package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
- package/plugins/interact/src/interact.scss +6 -0
- package/plugins/interact/src/reducer.js +25 -27
- package/plugins/interact/src/reducer.test.js +19 -6
- package/plugins/interact/src/utils/featureQueries.js +2 -2
- package/plugins/interact/src/utils/spatial.js +95 -0
- package/plugins/interact/src/utils/spatial.test.js +93 -0
- 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/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +2 -2
- package/plugins/search/src/components/Form/Form.jsx +1 -1
- package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
- package/plugins/search/src/datasets.js +1 -1
- package/plugins/search/src/defaults.js +3 -0
- package/plugins/search/src/events/fetchSuggestions.js +48 -53
- package/plugins/search/src/events/formHandlers.js +3 -2
- package/plugins/search/src/events/index.js +1 -1
- package/plugins/search/src/events/inputHandlers.js +3 -1
- package/plugins/search/src/events/suggestionHandlers.js +12 -3
- package/plugins/search/src/search.scss +4 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
- package/providers/beta/esri/src/esriProvider.js +1 -1
- package/providers/beta/esri/src/index.js +1 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/esm/index.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.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.js +3 -2
- package/providers/maplibre/src/index.js +22 -19
- package/providers/maplibre/src/maplibreProvider.js +13 -11
- package/providers/maplibre/src/utils/detectWebgl.js +1 -2
- package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
- package/providers/maplibre/src/utils/labels.js +174 -120
- package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
- package/providers/maplibre/src/utils/queryFeatures.js +146 -0
- package/providers/maplibre/src/utils/spatial.js +14 -17
- package/sonar-project.properties +39 -2
- package/src/App/components/Actions/Actions.jsx +2 -2
- package/src/App/components/Actions/Actions.module.scss +2 -2
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
- package/src/App/components/Logo/Logo.jsx +1 -1
- package/src/App/components/MapButton/MapButton.jsx +217 -41
- package/src/App/components/MapButton/MapButton.module.scss +32 -21
- package/src/App/components/MapButton/MapButton.test.jsx +128 -84
- package/src/App/components/Markers/Markers.jsx +1 -1
- package/src/App/components/Markers/Markers.module.scss +0 -5
- package/src/App/components/Panel/Panel.jsx +75 -45
- package/src/App/components/Panel/Panel.module.scss +18 -24
- package/src/App/components/Panel/Panel.test.jsx +18 -0
- package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
- package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
- package/src/App/components/Tooltip/Tooltip.jsx +4 -2
- package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
- package/src/App/components/Viewport/MapController.jsx +1 -1
- package/src/App/components/Viewport/MapStatus.jsx +1 -1
- package/src/App/components/Viewport/Viewport.jsx +2 -2
- package/src/App/components/Viewport/Viewport.module.scss +2 -2
- package/src/App/controls/keyboardActions.js +3 -2
- package/src/App/controls/keyboardShortcuts.js +4 -2
- package/src/App/hooks/useButtonStateEvaluator.js +58 -45
- package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
- package/src/App/hooks/useCrossHairAPI.js +49 -47
- package/src/App/hooks/useFocusVisible.js +2 -2
- package/src/App/hooks/useInterfaceAPI.js +16 -4
- package/src/App/hooks/useKeyboardHint.js +1 -1
- package/src/App/hooks/useKeyboardShortcuts.js +4 -2
- package/src/App/hooks/useMapAnnouncements.js +34 -32
- package/src/App/hooks/useMapEvents.js +12 -1
- package/src/App/hooks/useMapProviderOverrides.js +3 -3
- package/src/App/hooks/useMapStateSync.js +7 -1
- package/src/App/hooks/useMapURLSync.js +6 -1
- package/src/App/hooks/useMarkersAPI.js +88 -50
- package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
- package/src/App/hooks/useModalPanelBehaviour.js +46 -40
- package/src/App/hooks/useResizeObserver.js +2 -2
- package/src/App/initialiseApp.js +45 -38
- package/src/App/layout/Layout.jsx +6 -4
- package/src/App/layout/layout.module.scss +13 -17
- package/src/App/registry/keyboardShortcutRegistry.js +12 -2
- package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
- package/src/App/registry/mergeManifests.js +1 -1
- package/src/App/registry/panelRegistry.js +1 -1
- package/src/App/registry/panelRegistry.test.js +30 -1
- package/src/App/registry/pluginRegistry.js +14 -51
- package/src/App/registry/pluginRegistry.test.js +54 -6
- package/src/App/renderer/HtmlElementHost.jsx +186 -0
- package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
- package/src/App/renderer/mapButtons.js +11 -8
- package/src/App/renderer/mapButtons.test.js +6 -4
- package/src/App/renderer/mapControls.js +6 -0
- package/src/App/renderer/mapControls.test.js +10 -2
- package/src/App/renderer/mapPanels.js +39 -26
- package/src/App/renderer/mapPanels.test.js +29 -0
- package/src/App/renderer/pluginWrapper.test.js +28 -0
- package/src/App/renderer/slotHelpers.js +60 -0
- package/src/App/renderer/slotHelpers.test.js +82 -0
- package/src/App/store/AppProvider.jsx +3 -0
- package/src/App/store/AppProvider.test.jsx +40 -0
- package/src/App/store/PluginProvider.jsx +7 -5
- package/src/App/store/appActionsMap.js +25 -1
- package/src/App/store/appActionsMap.test.js +21 -0
- package/src/App/store/appReducer.js +1 -0
- package/src/App/store/mapActionsMap.js +12 -24
- package/src/App/store/mapReducer.js +1 -1
- package/src/InteractiveMap/InteractiveMap.js +79 -6
- package/src/InteractiveMap/InteractiveMap.test.js +164 -2
- package/src/InteractiveMap/behaviourController.js +12 -2
- package/src/InteractiveMap/behaviourController.test.js +82 -1
- package/src/InteractiveMap/deviceChecker.js +1 -1
- package/src/InteractiveMap/deviceChecker.test.js +2 -2
- package/src/InteractiveMap/historyManager.js +41 -5
- package/src/InteractiveMap/historyManager.test.js +37 -6
- package/src/InteractiveMap/polyfills.js +39 -0
- package/src/InteractiveMap/polyfills.test.js +127 -0
- package/src/config/appConfig.js +5 -5
- package/src/config/appConfig.test.js +107 -42
- package/src/config/defaults.js +1 -0
- package/src/index.umd.js +1 -1
- package/src/scss/main.scss +1 -0
- package/src/scss/settings/_dimensions.scss +6 -2
- package/src/services/eventBus.test.js +24 -1
- package/src/test-utils.js +1 -0
- package/src/types.js +5 -0
- package/src/utils/detectBreakpoint.js +78 -77
- package/src/utils/detectBreakpoint.test.js +50 -4
- package/src/utils/detectInterfaceType.js +4 -4
- package/src/utils/getIsFullscreen.js +3 -1
- package/src/utils/getSafeZoneInset.js +23 -7
- package/src/utils/getSafeZoneInset.test.js +18 -0
- package/src/utils/mapStateSync.js +3 -3
- package/src/utils/queryString.js +1 -1
- package/src/utils/stringToKebab.js +1 -1
- package/src/utils/toggleInertElements.js +37 -12
- package/webpack.dev.mjs +3 -4
- package/plugins/interact/src/utils/turfHelpers.js +0 -30
- package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
- package/plugins/search/src/utils/fetchDataset.js +0 -23
- package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
- package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import React, { useEffect, useState, useMemo } from 'react'
|
|
2
|
+
import { stringToKebab } from '../../../utils/stringToKebab'
|
|
3
|
+
import { useConfig } from '../../store/configContext'
|
|
4
|
+
import { useApp } from '../../store/appContext'
|
|
5
|
+
import { Icon } from '../Icon/Icon'
|
|
6
|
+
import { useEvaluateProp } from '../../hooks/useEvaluateProp.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PopupMenu — accessible keyboard-navigable dropdown menu component.
|
|
10
|
+
* Renders a role=menu list with selected item highlighting, keyboard nav (arrows/Home/End/Enter),
|
|
11
|
+
* and closes-on-outside-click/focus behavior. Skips hidden items during navigation.
|
|
12
|
+
*
|
|
13
|
+
* @component
|
|
14
|
+
* @param {string} popupMenuId
|
|
15
|
+
* Unique identifier for the menu element and item IDs (items get id={id}-item-{i})
|
|
16
|
+
* @param {string} pluginId
|
|
17
|
+
* Identifier for the owning plugin, passed to evaluateProp for context
|
|
18
|
+
* @param {string} buttonId
|
|
19
|
+
* Ref key of the button that triggered this menu; used to manage focus and detect outside clicks
|
|
20
|
+
* @param {string} [startPos]
|
|
21
|
+
* Initial selection strategy: 'first' (first visible), 'last' (last visible), or null/undefined (no selection)
|
|
22
|
+
* @param {number} [startIndex]
|
|
23
|
+
* Exact index to select on mount; takes precedence over startPos
|
|
24
|
+
* @param {MutableRefObject} menuRef
|
|
25
|
+
* Ref to the menu UL element; used for focus management and click-outside detection
|
|
26
|
+
* @param {Array} items
|
|
27
|
+
* Array of menu items {id, label, onClick, iconId?, iconSvgContent?, pressedWhen?} to render
|
|
28
|
+
* @param {Function} setIsOpen
|
|
29
|
+
* Callback to close the menu (called with false when user presses Escape/Tab or clicks outside)
|
|
30
|
+
* @returns {JSX.Element}
|
|
31
|
+
* A role=menu UL with keyboard handlers and visible=filtered LI children
|
|
32
|
+
*/
|
|
33
|
+
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
34
|
+
// sonarjs/disable-next-line function-name
|
|
35
|
+
export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen }) => {
|
|
36
|
+
const { id } = useConfig()
|
|
37
|
+
const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons } = useApp()
|
|
38
|
+
const instigatorKey = buttonId ?? instigatorId
|
|
39
|
+
const instigator = buttonRefs.current[instigatorKey]
|
|
40
|
+
const evaluateProp = useEvaluateProp()
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compute visible item indices by filtering out items in hiddenButtons.
|
|
44
|
+
* Memoized on items and hiddenButtons to avoid recomputation.
|
|
45
|
+
*/
|
|
46
|
+
const visibleIndices = useMemo(() => {
|
|
47
|
+
const visible = []
|
|
48
|
+
items.forEach((item, idx) => {
|
|
49
|
+
if (!hiddenButtons.has(item.id)) {
|
|
50
|
+
visible.push(idx)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
return visible
|
|
54
|
+
}, [items, hiddenButtons])
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Initialize selected index from startIndex (exact value) or startPos ('first'/'last')
|
|
58
|
+
* Falls back to -1 (no selection) if no initial value provided or all items are hidden.
|
|
59
|
+
*/
|
|
60
|
+
const [index, setIndex] = useState(() => {
|
|
61
|
+
if (typeof startIndex === 'number') {
|
|
62
|
+
return startIndex
|
|
63
|
+
}
|
|
64
|
+
if (startPos === 'first') {
|
|
65
|
+
return visibleIndices[0] ?? -1
|
|
66
|
+
}
|
|
67
|
+
if (startPos === 'last') {
|
|
68
|
+
return visibleIndices[visibleIndices.length - 1] ?? -1
|
|
69
|
+
}
|
|
70
|
+
return -1
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Helper: Close menu and return focus to instigator button.
|
|
75
|
+
* @param {Event} e
|
|
76
|
+
* The event that triggered the close (may have preventDefault called)
|
|
77
|
+
* @param {boolean} [preventDefault=false]
|
|
78
|
+
* Whether to call e.preventDefault() (true for Escape, false for Tab)
|
|
79
|
+
*/
|
|
80
|
+
const closeAndFocus = (e, preventDefault = false) => {
|
|
81
|
+
if (preventDefault && e?.preventDefault) {
|
|
82
|
+
e.preventDefault()
|
|
83
|
+
}
|
|
84
|
+
instigator.focus()
|
|
85
|
+
setIsOpen(false)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Helper: Navigate visible items via ArrowDown/ArrowUp.
|
|
90
|
+
* ArrowDown moves forward (wraps at end); ArrowUp moves backward (wraps at start).
|
|
91
|
+
* When no selection (-1), ArrowDown picks first, ArrowUp picks last.
|
|
92
|
+
* @param {KeyboardEvent} e
|
|
93
|
+
* Keyboard event (checked for ArrowDown/ArrowUp)
|
|
94
|
+
*/
|
|
95
|
+
const navigateVisible = (e) => {
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
const vis = visibleIndices
|
|
98
|
+
const n = vis.length
|
|
99
|
+
if (n === 0) {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const pos = vis.indexOf(index)
|
|
103
|
+
let nextPos
|
|
104
|
+
if (e.key === 'ArrowDown') {
|
|
105
|
+
nextPos = pos === -1 ? 0 : (pos + 1) % n
|
|
106
|
+
} else if (pos === -1) {
|
|
107
|
+
nextPos = n - 1
|
|
108
|
+
} else {
|
|
109
|
+
nextPos = (pos - 1 + n) % n
|
|
110
|
+
}
|
|
111
|
+
setIndex(vis[nextPos])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Helper: Handle Enter key press.
|
|
116
|
+
* Calls onClick on selected item (by index), then closes menu and returns focus to instigator.
|
|
117
|
+
* @param {KeyboardEvent} e
|
|
118
|
+
* The Enter keydown event
|
|
119
|
+
*/
|
|
120
|
+
const handleEnter = (e) => {
|
|
121
|
+
e.preventDefault()
|
|
122
|
+
instigator.focus()
|
|
123
|
+
setIsOpen(false)
|
|
124
|
+
items[index]?.onClick(e.nativeEvent)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Main keyboard handler for the menu.
|
|
129
|
+
* Dispatches to helpers or directly handles:
|
|
130
|
+
* - Escape/Esc: close & focus instigator
|
|
131
|
+
* - Tab: close & focus instigator (without preventDefault)
|
|
132
|
+
* - ArrowDown/ArrowUp: navigate visible items
|
|
133
|
+
* - Home: select first visible
|
|
134
|
+
* - End: select last visible
|
|
135
|
+
* - Enter: call selected item's onClick & close
|
|
136
|
+
* @param {KeyboardEvent} e
|
|
137
|
+
* Keyboard event from menu onKeyDown
|
|
138
|
+
*/
|
|
139
|
+
const handleMenuKeyDown = (e) => {
|
|
140
|
+
if (['Escape', 'Esc'].includes(e.key)) {
|
|
141
|
+
closeAndFocus(e, true)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (e.key === 'Tab') {
|
|
145
|
+
closeAndFocus(e)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
|
|
149
|
+
navigateVisible(e)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (e.key === 'Home' && visibleIndices.length) {
|
|
153
|
+
setIndex(visibleIndices[0])
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (e.key === 'End' && visibleIndices.length) {
|
|
157
|
+
setIndex(visibleIndices[visibleIndices.length - 1])
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (e.key === 'Enter') {
|
|
161
|
+
handleEnter(e)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Helper: Close menu if click/focus originated outside menu and instigator.
|
|
167
|
+
* Registered on document focusin and pointerdown events to detect outside interactions.
|
|
168
|
+
* @param {Event} e
|
|
169
|
+
* The focusin or pointerdown event
|
|
170
|
+
*/
|
|
171
|
+
const handleOutside = (e) => {
|
|
172
|
+
if (menuRef.current?.contains(e.target) || buttonRefs.current[instigatorKey]?.contains(e.target)) {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
setIsOpen(false)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Helper: Handle item click.
|
|
180
|
+
* Looks up buttonConfig[item.id] to decide which onClick to call (item's or buttonConfig's).
|
|
181
|
+
* Closes menu after invoking onClick.
|
|
182
|
+
* @param {React.MouseEvent} e
|
|
183
|
+
* React synthetic event from the LI click
|
|
184
|
+
* @param {Object} item
|
|
185
|
+
* The clicked item object with {id, label, onClick, ...}
|
|
186
|
+
*/
|
|
187
|
+
const handleItemClick = (e, item) => {
|
|
188
|
+
const menuItemConfig = buttonConfig[item.id]
|
|
189
|
+
setIsOpen(false)
|
|
190
|
+
if (typeof menuItemConfig?.onClick !== 'function') {
|
|
191
|
+
item.onClick?.(e.nativeEvent)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
menuItemConfig.onClick(e, evaluateProp(ctx => ctx, pluginId))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
menuRef.current?.focus()
|
|
199
|
+
|
|
200
|
+
// If startPos changes on mount, ensure selection respects visible items.
|
|
201
|
+
if (startPos === 'first') {
|
|
202
|
+
setIndex(visibleIndices[0] ?? -1)
|
|
203
|
+
} else if (startPos === 'last') {
|
|
204
|
+
setIndex(visibleIndices[visibleIndices.length - 1] ?? -1)
|
|
205
|
+
} else {
|
|
206
|
+
// No action
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
document.addEventListener('focusin', handleOutside)
|
|
210
|
+
document.addEventListener('pointerdown', handleOutside)
|
|
211
|
+
|
|
212
|
+
return () => {
|
|
213
|
+
document.removeEventListener('focusin', handleOutside)
|
|
214
|
+
document.removeEventListener('pointerdown', handleOutside)
|
|
215
|
+
}
|
|
216
|
+
}, [])
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<ul // NOSONAR
|
|
220
|
+
ref={menuRef}
|
|
221
|
+
id={popupMenuId}
|
|
222
|
+
className='fm-c-popup-menu'
|
|
223
|
+
role='menu' // NOSONAR
|
|
224
|
+
tabIndex='-1'
|
|
225
|
+
aria-labelledby={instigatorKey}
|
|
226
|
+
aria-activedescendant={index >= 0 ? `${id}-${stringToKebab(items[index].id)}` : undefined}
|
|
227
|
+
onKeyDown={handleMenuKeyDown}
|
|
228
|
+
>
|
|
229
|
+
{items.map((item, i) => (
|
|
230
|
+
<li // NOSONAR
|
|
231
|
+
key={item.id}
|
|
232
|
+
id={`${id}-${stringToKebab(items[i].id)}`}
|
|
233
|
+
className={`fm-c-popup-menu__item${index === i ? ' fm-c-popup-menu__item--selected' : ''}`}
|
|
234
|
+
role='menuitem' // NOSONAR
|
|
235
|
+
aria-disabled={disabledButtons.has(items[i].id) || undefined} // NOSONAR
|
|
236
|
+
aria-pressed={(items[i].isPressed !== undefined || items[i].pressedWhen) ? pressedButtons.has(items[i].id) : undefined} // NOSONAR
|
|
237
|
+
style={hiddenButtons.has(items[i].id) ? { display: 'none' } : undefined}
|
|
238
|
+
onClick={(e) => handleItemClick(e, items[i])} // NOSONAR
|
|
239
|
+
>
|
|
240
|
+
{(item.iconId || item.iconSvgContent) && <Icon id={item.iconId} svgContent={item.iconSvgContent} />}
|
|
241
|
+
<span className='fm-c-popup-menu__item-label'>{item.label}</span>
|
|
242
|
+
</li>
|
|
243
|
+
))}
|
|
244
|
+
</ul>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ===================================================
|
|
2
|
+
// Component: PopupMenu
|
|
3
|
+
// ===================================================
|
|
4
|
+
|
|
5
|
+
@use '../../../scss/tools/index' as tools;
|
|
6
|
+
|
|
7
|
+
// 1. Base styles
|
|
8
|
+
|
|
9
|
+
.fm-c-popup-menu {
|
|
10
|
+
@include tools.border-focus-base(
|
|
11
|
+
$is-inset: false,
|
|
12
|
+
$type: button
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
background-color: var(--background-color);
|
|
16
|
+
position: absolute;
|
|
17
|
+
list-style: none;
|
|
18
|
+
margin: 0;
|
|
19
|
+
padding: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 2. Elements
|
|
23
|
+
.fm-c-popup-menu__item {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
padding: var(--divider-gap);
|
|
27
|
+
border-top: 1px solid var(--button-hover-color);
|
|
28
|
+
text-align: left;
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
min-width: var(--popup-menu-min-width);
|
|
31
|
+
max-width: var(--popup-menu-max-width);
|
|
32
|
+
|
|
33
|
+
&:first-child {
|
|
34
|
+
border-top: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&:hover:not(.fm-c-popup-menu__item--selected) {
|
|
38
|
+
@media (hover: hover) and (pointer: fine) {
|
|
39
|
+
background-color: var(--button-hover-color);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&:hover:not(.fm-c-popup-menu__item--selected) .fm-c-popup-menu__item-label {
|
|
44
|
+
text-decoration: underline;
|
|
45
|
+
text-underline-offset: 2px;
|
|
46
|
+
text-decoration-thickness: 2px;
|
|
47
|
+
text-decoration-color: var(--foreground-color);
|
|
48
|
+
text-decoration-skip-ink: none;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
&--selected {
|
|
52
|
+
background-color: var(--button-hover-color);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
&--selected .fm-c-popup-menu__item-label {
|
|
56
|
+
@include tools.link-focus;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
svg + span {
|
|
60
|
+
margin-left: var(--button-icon-label-offset);
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Modifiers
|
|
66
|
+
.im-o-app__actions .fm-c-popup-menu {
|
|
67
|
+
left: 50%;
|
|
68
|
+
transform: translateX(-50%);
|
|
69
|
+
bottom: calc(100% + (var(--primary-gap) * 2));
|
|
70
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { PopupMenu } from './PopupMenu'
|
|
4
|
+
|
|
5
|
+
// Mock Icon
|
|
6
|
+
jest.mock('../Icon/Icon', () => ({
|
|
7
|
+
Icon: ({ id, svgContent }) => <svg data-testid={id || 'custom-icon'} data-svg={svgContent} />
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
// Mock useEvaluateProp
|
|
11
|
+
const mockEvaluateProp = jest.fn((fn) => fn({ /* mock context */ }))
|
|
12
|
+
jest.mock('../../hooks/useEvaluateProp.js', () => ({
|
|
13
|
+
useEvaluateProp: () => mockEvaluateProp
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
// Mock useConfig to provide `id` used for stable item ids
|
|
17
|
+
jest.mock('../../store/configContext', () => ({ useConfig: () => ({ id: 'app' }) }))
|
|
18
|
+
|
|
19
|
+
// Mock useApp
|
|
20
|
+
const mockUseApp = {
|
|
21
|
+
buttonRefs: { current: { instigator: { focus: jest.fn(), contains: () => false } } },
|
|
22
|
+
buttonConfig: {},
|
|
23
|
+
hiddenButtons: new Set(),
|
|
24
|
+
disabledButtons: new Set(),
|
|
25
|
+
pressedButtons: new Set()
|
|
26
|
+
}
|
|
27
|
+
jest.mock('../../store/appContext', () => ({
|
|
28
|
+
useApp: jest.fn(() => mockUseApp)
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
describe('PopupMenu', () => {
|
|
32
|
+
const items = [
|
|
33
|
+
{ id: 'item1', label: 'Item 1', onClick: jest.fn() },
|
|
34
|
+
{ id: 'item2', label: 'Item 2', onClick: jest.fn(), iconId: 'icon2', pressedWhen: () => true }
|
|
35
|
+
]
|
|
36
|
+
let menuRef
|
|
37
|
+
const mockSetIsOpen = jest.fn()
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
jest.clearAllMocks()
|
|
41
|
+
menuRef = { current: { focus: jest.fn(), contains: () => false } }
|
|
42
|
+
|
|
43
|
+
// Reset mockUseApp state
|
|
44
|
+
mockUseApp.buttonRefs.current = { instigator: { focus: jest.fn(), contains: () => false } }
|
|
45
|
+
mockUseApp.buttonConfig = {}
|
|
46
|
+
mockUseApp.hiddenButtons = new Set()
|
|
47
|
+
mockUseApp.disabledButtons = new Set()
|
|
48
|
+
mockUseApp.pressedButtons = new Set()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const renderMenu = (props = {}) =>
|
|
52
|
+
render(
|
|
53
|
+
<PopupMenu
|
|
54
|
+
id='menu'
|
|
55
|
+
pluginId='plugin1'
|
|
56
|
+
instigatorId='instigator'
|
|
57
|
+
startIndex={0}
|
|
58
|
+
menuRef={menuRef}
|
|
59
|
+
items={items}
|
|
60
|
+
setIsOpen={mockSetIsOpen}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Test helpers to reduce duplication
|
|
66
|
+
const setHidden = (ids = []) => {
|
|
67
|
+
mockUseApp.hiddenButtons = new Set(ids)
|
|
68
|
+
}
|
|
69
|
+
const setPressed = (ids = []) => {
|
|
70
|
+
mockUseApp.pressedButtons = new Set(ids)
|
|
71
|
+
}
|
|
72
|
+
const renderDirect = (opts = {}) => {
|
|
73
|
+
const directMenuRef = { current: { focus: jest.fn(), contains: () => false } }
|
|
74
|
+
render(
|
|
75
|
+
<PopupMenu
|
|
76
|
+
id='direct'
|
|
77
|
+
pluginId='plugin1'
|
|
78
|
+
instigatorId='instigator'
|
|
79
|
+
menuRef={directMenuRef}
|
|
80
|
+
items={items}
|
|
81
|
+
setIsOpen={mockSetIsOpen}
|
|
82
|
+
{...opts}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
return directMenuRef
|
|
86
|
+
}
|
|
87
|
+
const dispatchFocusIn = ({ menuContains = false, instigatorContains = false, target = document.body } = {}) => {
|
|
88
|
+
jest.spyOn(menuRef.current, 'contains').mockReturnValue(menuContains)
|
|
89
|
+
jest.spyOn(mockUseApp.buttonRefs.current.instigator, 'contains').mockReturnValue(instigatorContains)
|
|
90
|
+
const ev = new FocusEvent('focusin')
|
|
91
|
+
Object.defineProperty(ev, 'target', { value: target, enumerable: true })
|
|
92
|
+
document.dispatchEvent(ev)
|
|
93
|
+
}
|
|
94
|
+
const expectSelected = (text) => expect(screen.getByText(text).parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
95
|
+
const expectNotSelected = (text) => expect(screen.getByText(text).parentElement).not.toHaveClass('fm-c-popup-menu__item--selected')
|
|
96
|
+
|
|
97
|
+
it('renders items with labels and icons', () => {
|
|
98
|
+
renderMenu()
|
|
99
|
+
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
|
100
|
+
expect(screen.getByText('Item 2')).toBeInTheDocument()
|
|
101
|
+
expect(screen.getByTestId('icon2')).toBeInTheDocument()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('applies selected class to active index', () => {
|
|
105
|
+
renderMenu({ startIndex: 1 })
|
|
106
|
+
expect(screen.getByText('Item 2').parentElement).toHaveClass(
|
|
107
|
+
'fm-c-popup-menu__item--selected'
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('handles ArrowDown, ArrowUp, Home, End keys', () => {
|
|
112
|
+
renderMenu()
|
|
113
|
+
const ul = screen.getByRole('menu')
|
|
114
|
+
fireEvent.keyDown(ul, { key: 'ArrowDown' })
|
|
115
|
+
expect(screen.getByText('Item 2').parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
116
|
+
fireEvent.keyDown(ul, { key: 'ArrowDown' })
|
|
117
|
+
expect(screen.getByText('Item 1').parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
118
|
+
fireEvent.keyDown(ul, { key: 'ArrowUp' })
|
|
119
|
+
expect(screen.getByText('Item 2').parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
120
|
+
fireEvent.keyDown(ul, { key: 'Home' })
|
|
121
|
+
expect(screen.getByText('Item 1').parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
122
|
+
fireEvent.keyDown(ul, { key: 'End' })
|
|
123
|
+
expect(screen.getByText('Item 2').parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('handles Enter key to call onClick and close menu', () => {
|
|
127
|
+
renderMenu({ startIndex: 1 })
|
|
128
|
+
const ul = screen.getByRole('menu')
|
|
129
|
+
fireEvent.keyDown(ul, { key: 'Enter' })
|
|
130
|
+
expect(items[1].onClick).toHaveBeenCalled()
|
|
131
|
+
expect(mockSetIsOpen).toHaveBeenCalled()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('handles Escape and Tab to close menu', () => {
|
|
135
|
+
renderMenu()
|
|
136
|
+
const ul = screen.getByRole('menu')
|
|
137
|
+
fireEvent.keyDown(ul, { key: 'Escape' })
|
|
138
|
+
expect(mockSetIsOpen).toHaveBeenCalled()
|
|
139
|
+
fireEvent.keyDown(ul, { key: 'Tab' })
|
|
140
|
+
expect(mockSetIsOpen).toHaveBeenCalled()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('calls item onClick directly if no buttonConfig', () => {
|
|
144
|
+
renderMenu()
|
|
145
|
+
fireEvent.click(screen.getByText('Item 1'))
|
|
146
|
+
expect(items[0].onClick).toHaveBeenCalled()
|
|
147
|
+
expect(mockSetIsOpen).toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('calls buttonConfig.onClick with evaluateProp if defined', () => {
|
|
151
|
+
const mockOnClick = jest.fn()
|
|
152
|
+
mockUseApp.buttonConfig = { item2: { onClick: mockOnClick } }
|
|
153
|
+
|
|
154
|
+
renderMenu()
|
|
155
|
+
fireEvent.click(screen.getByText('Item 2'))
|
|
156
|
+
|
|
157
|
+
expect(mockOnClick).toHaveBeenCalled()
|
|
158
|
+
expect(mockEvaluateProp).toHaveBeenCalled()
|
|
159
|
+
expect(mockSetIsOpen).toHaveBeenCalled()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('does nothing if click is inside menu', () => {
|
|
163
|
+
renderMenu()
|
|
164
|
+
const element = document.createElement('div')
|
|
165
|
+
dispatchFocusIn({ menuContains: true, target: element })
|
|
166
|
+
expect(mockSetIsOpen).not.toHaveBeenCalled()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('does nothing if click is inside instigator', () => {
|
|
170
|
+
renderMenu()
|
|
171
|
+
dispatchFocusIn({ menuContains: false, instigatorContains: true, target: document.createElement('div') })
|
|
172
|
+
expect(mockSetIsOpen).not.toHaveBeenCalled()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('returns early if click/focus target is inside menu or instigator', () => {
|
|
176
|
+
// menuRef contains → early return
|
|
177
|
+
renderMenu()
|
|
178
|
+
dispatchFocusIn({ menuContains: true, target: document.createElement('div') })
|
|
179
|
+
expect(mockSetIsOpen).not.toHaveBeenCalled()
|
|
180
|
+
|
|
181
|
+
// instigator contains → early return
|
|
182
|
+
jest.clearAllMocks()
|
|
183
|
+
jest.restoreAllMocks()
|
|
184
|
+
jest.spyOn(mockUseApp.buttonRefs.current.instigator, 'contains').mockReturnValue(true)
|
|
185
|
+
renderMenu()
|
|
186
|
+
dispatchFocusIn({ menuContains: false, instigatorContains: true, target: document.createElement('div') })
|
|
187
|
+
expect(mockSetIsOpen).not.toHaveBeenCalled()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('evaluates line 51 but continues if target is outside', () => {
|
|
191
|
+
renderMenu()
|
|
192
|
+
dispatchFocusIn({ menuContains: false, instigatorContains: false, target: document.body })
|
|
193
|
+
expect(mockSetIsOpen).toHaveBeenCalled()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('closes when clicking outside', () => {
|
|
197
|
+
renderMenu()
|
|
198
|
+
document.dispatchEvent(new MouseEvent('pointerdown', { target: document.body }))
|
|
199
|
+
expect(mockSetIsOpen).toHaveBeenCalled()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('startPos="first" skips hidden items when selecting initial item', () => {
|
|
203
|
+
setHidden(['item1'])
|
|
204
|
+
renderMenu({ startPos: 'first', startIndex: undefined })
|
|
205
|
+
expectSelected('Item 2')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('startPos="last" skips hidden items when selecting initial item', () => {
|
|
209
|
+
setHidden(['item2'])
|
|
210
|
+
renderMenu({ startPos: 'last', startIndex: undefined })
|
|
211
|
+
expectSelected('Item 1')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('ArrowDown/ArrowUp from no selection picks first/last visible', () => {
|
|
215
|
+
renderMenu({ startIndex: -1 })
|
|
216
|
+
const ul = screen.getByRole('menu')
|
|
217
|
+
fireEvent.keyDown(ul, { key: 'ArrowDown' })
|
|
218
|
+
expect(screen.getByText('Item 1').parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
219
|
+
fireEvent.keyDown(ul, { key: 'ArrowUp' })
|
|
220
|
+
expect(screen.getByText('Item 2').parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('ArrowUp from no selection picks last visible', () => {
|
|
224
|
+
renderMenu({ startIndex: -1 })
|
|
225
|
+
const ul = screen.getByRole('menu')
|
|
226
|
+
fireEvent.keyDown(ul, { key: 'ArrowUp' })
|
|
227
|
+
expect(screen.getByText('Item 2').parentElement).toHaveClass('fm-c-popup-menu__item--selected')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('initializes with no selection when neither startIndex nor startPos provided', () => {
|
|
231
|
+
renderMenu({ startIndex: undefined, startPos: undefined })
|
|
232
|
+
expect(screen.getByText('Item 1').parentElement).not.toHaveClass('fm-c-popup-menu__item--selected')
|
|
233
|
+
expect(screen.getByText('Item 2').parentElement).not.toHaveClass('fm-c-popup-menu__item--selected')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('does nothing when navigating and no visible items', () => {
|
|
237
|
+
mockUseApp.hiddenButtons = new Set(['item1', 'item2'])
|
|
238
|
+
renderMenu({ startIndex: -1 })
|
|
239
|
+
const ul = screen.getByRole('menu')
|
|
240
|
+
fireEvent.keyDown(ul, { key: 'ArrowDown' })
|
|
241
|
+
expect(screen.getByText('Item 1').parentElement).not.toHaveClass('fm-c-popup-menu__item--selected')
|
|
242
|
+
expect(screen.getByText('Item 2').parentElement).not.toHaveClass('fm-c-popup-menu__item--selected')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('Enter with no selection closes but does not call any item onClick', () => {
|
|
246
|
+
renderMenu({ startIndex: -1 })
|
|
247
|
+
const ul = screen.getByRole('menu')
|
|
248
|
+
fireEvent.keyDown(ul, { key: 'Enter' })
|
|
249
|
+
expect(items[0].onClick).not.toHaveBeenCalled()
|
|
250
|
+
expect(items[1].onClick).not.toHaveBeenCalled()
|
|
251
|
+
expect(mockSetIsOpen).toHaveBeenCalled()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('Escape calls focus on instigator', () => {
|
|
255
|
+
const focusSpy = jest.fn()
|
|
256
|
+
mockUseApp.buttonRefs.current.instigator.focus = focusSpy
|
|
257
|
+
renderMenu()
|
|
258
|
+
const ul = screen.getByRole('menu')
|
|
259
|
+
fireEvent.keyDown(ul, { key: 'Escape' })
|
|
260
|
+
expect(focusSpy).toHaveBeenCalled()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('direct render without startIndex respects startPos="first" (covers initializer and effect)', () => {
|
|
264
|
+
renderDirect({ startPos: 'first' })
|
|
265
|
+
expectSelected('Item 1')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('direct render without startIndex respects startPos="last" (covers initializer and effect)', () => {
|
|
269
|
+
renderDirect({ startPos: 'last' })
|
|
270
|
+
expectSelected('Item 2')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('startPos first with no visible items falls back to no selection', () => {
|
|
274
|
+
setHidden(['item1', 'item2'])
|
|
275
|
+
renderDirect({ startPos: 'first' })
|
|
276
|
+
expectNotSelected('Item 1')
|
|
277
|
+
expectNotSelected('Item 2')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('startPos last with no visible items falls back to no selection', () => {
|
|
281
|
+
setHidden(['item1', 'item2'])
|
|
282
|
+
renderDirect({ startPos: 'last' })
|
|
283
|
+
expectNotSelected('Item 1')
|
|
284
|
+
expectNotSelected('Item 2')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('sets aria-pressed and hides items based on pressedWhen and hiddenButtons', () => {
|
|
288
|
+
setPressed(['item2'])
|
|
289
|
+
setHidden(['item1'])
|
|
290
|
+
renderMenu()
|
|
291
|
+
const item1 = screen.getByText('Item 1').parentElement
|
|
292
|
+
const item2 = screen.getByText('Item 2').parentElement
|
|
293
|
+
expect(item1).toHaveStyle('display: none')
|
|
294
|
+
expect(item2).toHaveAttribute('aria-pressed', 'true')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('ignores unrelated keys (covers Enter false branch)', () => {
|
|
298
|
+
renderMenu({ startIndex: 1 })
|
|
299
|
+
const ul = screen.getByRole('menu')
|
|
300
|
+
fireEvent.keyDown(ul, { key: 'a' })
|
|
301
|
+
expect(items[1].onClick).not.toHaveBeenCalled()
|
|
302
|
+
expect(mockSetIsOpen).not.toHaveBeenCalled()
|
|
303
|
+
})
|
|
304
|
+
})
|
|
@@ -71,6 +71,8 @@ export const Tooltip = ({ children, content }) => {
|
|
|
71
71
|
childRef(node)
|
|
72
72
|
} else if (childRef && typeof childRef === 'object') {
|
|
73
73
|
childRef.current = node
|
|
74
|
+
} else {
|
|
75
|
+
// No action
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -80,13 +82,13 @@ export const Tooltip = ({ children, content }) => {
|
|
|
80
82
|
onMouseLeave: hide,
|
|
81
83
|
onMouseDown: cancel,
|
|
82
84
|
onKeyDown: cancel,
|
|
83
|
-
onFocus: (
|
|
85
|
+
onFocus: () => {
|
|
84
86
|
if (interfaceType === 'keyboard') {
|
|
85
87
|
show()
|
|
86
88
|
}
|
|
87
89
|
setFocused(true)
|
|
88
90
|
},
|
|
89
|
-
onBlur: (
|
|
91
|
+
onBlur: () => {
|
|
90
92
|
hide()
|
|
91
93
|
setFocused(false)
|
|
92
94
|
},
|
|
@@ -21,7 +21,7 @@ export function getTooltipPosition (triggerEl, containerEl) {
|
|
|
21
21
|
|
|
22
22
|
// Step 1: Find the actual smallest space
|
|
23
23
|
const entries = Object.entries(space)
|
|
24
|
-
const sorted = entries.
|
|
24
|
+
const sorted = entries.toSorted(([, a], [, b]) => a - b)
|
|
25
25
|
const [leastSide, leastValue] = sorted[0]
|
|
26
26
|
|
|
27
27
|
// Step 2: Prefer horizontal if it's close in smallness
|
|
@@ -26,7 +26,7 @@ export const MapController = ({ mapContainerRef }) => {
|
|
|
26
26
|
// Initialize map provider when props are available
|
|
27
27
|
useEffect(() => {
|
|
28
28
|
if (!safeZoneInset || !isLayoutReady || !mapStyle || !mapSize || isMapInitialized.current) {
|
|
29
|
-
return
|
|
29
|
+
return undefined
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
requestAnimationFrame(() => {
|
|
@@ -8,7 +8,7 @@ export const MapStatus = () => {
|
|
|
8
8
|
const { mapStatusRef } = useService()
|
|
9
9
|
|
|
10
10
|
return (
|
|
11
|
-
<div
|
|
11
|
+
<div // NOSONAR: div + status role is semantically correct here, <output> implies a calculation result
|
|
12
12
|
ref={mapStatusRef}
|
|
13
13
|
role='status'
|
|
14
14
|
className='im-c-viewport__status'
|
|
@@ -30,7 +30,7 @@ export const Viewport = ({ keyboardHintPortalRef }) => {
|
|
|
30
30
|
|
|
31
31
|
// Attach map events
|
|
32
32
|
useMapEvents({
|
|
33
|
-
[events.MAP_CLICK]: (
|
|
33
|
+
[events.MAP_CLICK]: () => mapProvider?.clearHighlightedLabel?.()
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
// Manage keyboard hint visibility using local state
|
|
@@ -53,7 +53,7 @@ export const Viewport = ({ keyboardHintPortalRef }) => {
|
|
|
53
53
|
useEffect(() => {
|
|
54
54
|
const mainEl = layoutRefs.mainRef?.current
|
|
55
55
|
if (!mainEl) {
|
|
56
|
-
return
|
|
56
|
+
return undefined
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
mainEl.classList.toggle('im-o-app__main--keyboard-hint-visible', showHint)
|