@defra/interactive-map 0.0.16-alpha → 0.0.17-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/images/slot-map.svg +264 -0
- 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/slots.md +16 -15
- package/docs/api.md +3 -3
- package/docs/getting-started.md +4 -1
- package/docs/plugins/datasets.md +561 -0
- package/docs/plugins.md +1 -1
- package/package.json +2 -2
- package/plugins/beta/datasets/dist/css/index.css +85 -15
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/index.js +1 -1
- package/plugins/beta/datasets/src/DatasetsInit.jsx +23 -8
- package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
- package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +113 -0
- package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +69 -0
- package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +338 -0
- package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +48 -0
- package/plugins/beta/datasets/src/api/addDataset.js +2 -8
- package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
- package/plugins/beta/datasets/src/api/getStyle.js +13 -0
- package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
- package/plugins/beta/datasets/src/api/setData.js +8 -0
- package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
- package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
- package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
- package/plugins/beta/datasets/src/api/setStyle.js +22 -0
- package/plugins/beta/datasets/src/datasets.js +29 -55
- package/plugins/beta/datasets/src/defaults.js +42 -8
- package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
- package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
- package/plugins/beta/datasets/src/manifest.js +24 -16
- package/plugins/beta/datasets/src/panels/Key.jsx +128 -50
- package/plugins/beta/datasets/src/panels/Key.module.scss +48 -9
- package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
- package/plugins/beta/datasets/src/panels/Layers.module.scss +50 -8
- package/plugins/beta/datasets/src/reducer.js +128 -9
- package/plugins/beta/datasets/src/styles/patterns.js +157 -0
- package/plugins/beta/datasets/src/utils/bbox.js +7 -5
- package/plugins/beta/datasets/src/utils/filters.js +5 -2
- package/plugins/beta/datasets/src/utils/mergeSublayer.js +78 -0
- 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/src/draw.scss +0 -7
- package/plugins/beta/draw-ml/src/manifest.js +16 -16
- 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 +5 -5
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/src/manifest.js +1 -1
- package/plugins/beta/scale-bar/dist/css/index.css +1 -1
- package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
- package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
- package/plugins/beta/scale-bar/src/index.test.js +3 -3
- package/plugins/beta/scale-bar/src/manifest.js +3 -3
- package/plugins/beta/scale-bar/src/scaleBar.scss +2 -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/src/interact.scss +0 -7
- package/plugins/interact/src/manifest.js +14 -18
- package/plugins/interact/src/manifest.test.js +3 -1
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/src/components/Form/Form.module.scss +2 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/utils/highlightFeatures.js +1 -0
- package/providers/maplibre/src/utils/highlightFeatures.test.js +1 -0
- package/src/App/components/Actions/Actions.jsx +2 -2
- package/src/App/components/Actions/Actions.module.scss +0 -7
- package/src/App/components/Actions/Actions.test.jsx +1 -1
- package/src/App/components/Icon/Icon.jsx +3 -2
- package/src/App/components/Icon/Icon.module.scss +4 -0
- package/src/App/components/Icon/Icon.test.jsx +43 -4
- package/src/App/components/MapButton/MapButton.jsx +42 -17
- package/src/App/components/MapButton/MapButton.module.scss +4 -13
- package/src/App/components/MapButton/MapButton.test.jsx +27 -3
- package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
- package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
- package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
- package/src/App/hooks/useButtonStateEvaluator.js +12 -2
- package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
- package/src/App/hooks/useInterfaceAPI.js +6 -0
- package/src/App/hooks/useLayoutMeasurements.js +84 -18
- package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
- package/src/App/layout/Layout.jsx +12 -7
- package/src/App/layout/Layout.test.jsx +2 -2
- package/src/App/layout/layout.module.scss +67 -29
- package/src/App/registry/pluginRegistry.js +1 -1
- package/src/App/renderer/HtmlElementHost.jsx +2 -1
- package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
- package/src/App/renderer/mapButtons.js +1 -1
- package/src/App/renderer/mapPanels.test.js +2 -2
- package/src/App/renderer/slotHelpers.js +2 -2
- package/src/App/renderer/slotHelpers.test.js +5 -5
- package/src/App/renderer/slots.js +9 -5
- package/src/App/store/AppProvider.jsx +3 -1
- package/src/App/store/AppProvider.test.jsx +1 -1
- package/src/App/store/ServiceProvider.jsx +3 -1
- package/src/App/store/appActionsMap.js +16 -0
- package/src/App/store/appActionsMap.test.js +27 -0
- package/src/App/store/appDispatchMiddleware.js +1 -1
- package/src/App/store/appDispatchMiddleware.test.js +2 -2
- package/src/App/store/appReducer.js +2 -0
- package/src/InteractiveMap/InteractiveMap.js +4 -0
- package/src/config/appConfig.js +5 -2
- package/src/config/events.js +28 -0
- package/src/scss/main.scss +1 -0
- package/src/scss/settings/_dimensions.scss +0 -1
- package/src/utils/getSafeZoneInset.js +9 -7
- package/src/utils/getSafeZoneInset.test.js +10 -10
- package/webpack.dev.mjs +1 -1
- package/docs/api/slot-map.svg +0 -1
- package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
- package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
- package/plugins/beta/datasets/src/api/showDataset.js +0 -14
- package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
- package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
- package/plugins/beta/datasets/src/mapLayers.js +0 -164
- /package/src/{utils → services}/logger.js +0 -0
- /package/src/{utils → services}/logger.test.js +0 -0
|
@@ -1,280 +1,59 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createPortal } from 'react-dom'
|
|
2
2
|
import { stringToKebab } from '../../../utils/stringToKebab'
|
|
3
3
|
import { useConfig } from '../../store/configContext'
|
|
4
4
|
import { useApp } from '../../store/appContext'
|
|
5
5
|
import { Icon } from '../Icon/Icon'
|
|
6
6
|
import { useEvaluateProp } from '../../hooks/useEvaluateProp.js'
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 }) => {
|
|
7
|
+
import { usePopupMenu } from './usePopupMenu'
|
|
8
|
+
|
|
9
|
+
const MenuItem = ({ item, isSelected, hiddenButtons, disabledButtons, pressedButtons, id, onItemClick }) => (
|
|
10
|
+
<li // NOSONAR
|
|
11
|
+
id={`${id}-${stringToKebab(item.id)}`}
|
|
12
|
+
className={`im-c-popup-menu__item${isSelected ? ' im-c-popup-menu__item--selected' : ''}`}
|
|
13
|
+
role={item.isPressed !== undefined || item.pressedWhen ? 'menuitemcheckbox' : 'menuitem'} // NOSONAR
|
|
14
|
+
aria-disabled={disabledButtons.has(item.id) || undefined} // NOSONAR
|
|
15
|
+
aria-checked={(item.isPressed !== undefined || item.pressedWhen) ? pressedButtons.has(item.id) : undefined} // NOSONAR
|
|
16
|
+
style={hiddenButtons.has(item.id) ? { display: 'none' } : undefined}
|
|
17
|
+
onClick={(e) => onItemClick(e, item)} // NOSONAR
|
|
18
|
+
>
|
|
19
|
+
{(item.iconId || item.iconSvgContent) && <Icon id={item.iconId} svgContent={item.iconSvgContent} />}
|
|
20
|
+
<span className='im-c-popup-menu__item-label'>{item.label}</span>
|
|
21
|
+
</li>
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect }) => {
|
|
36
25
|
const { id } = useConfig()
|
|
37
|
-
const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons } = useApp()
|
|
26
|
+
const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons, layoutRefs } = useApp()
|
|
38
27
|
const instigatorKey = buttonId ?? instigatorId
|
|
39
28
|
const instigator = buttonRefs.current[instigatorKey]
|
|
40
29
|
const evaluateProp = useEvaluateProp()
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
31
|
+
const { index, handleMenuKeyDown, handleItemClick, menuStyle, menuDirection, menuHAlign } = usePopupMenu({
|
|
32
|
+
items,
|
|
33
|
+
hiddenButtons,
|
|
34
|
+
startIndex,
|
|
35
|
+
startPos,
|
|
36
|
+
instigator,
|
|
37
|
+
instigatorKey,
|
|
38
|
+
buttonRefs,
|
|
39
|
+
buttonConfig,
|
|
40
|
+
disabledButtons,
|
|
41
|
+
pluginId,
|
|
42
|
+
evaluateProp,
|
|
43
|
+
id,
|
|
44
|
+
menuRef,
|
|
45
|
+
setIsOpen,
|
|
46
|
+
buttonRect
|
|
71
47
|
})
|
|
72
48
|
|
|
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: Invoke a menu item's action via buttonConfig or item.onClick.
|
|
116
|
-
* @param {Event} e - The triggering event
|
|
117
|
-
* @param {Object} item - The item to activate
|
|
118
|
-
*/
|
|
119
|
-
const activateItem = (e, item) => {
|
|
120
|
-
const menuItemConfig = buttonConfig[item.id]
|
|
121
|
-
if (typeof menuItemConfig?.onClick === 'function') {
|
|
122
|
-
menuItemConfig.onClick(e, evaluateProp(ctx => ctx, pluginId))
|
|
123
|
-
} else if (typeof item.onClick === 'function') {
|
|
124
|
-
item.onClick(e.nativeEvent)
|
|
125
|
-
} else {
|
|
126
|
-
// No action
|
|
127
|
-
}
|
|
128
|
-
// For keyboard events, also dispatch a synthetic click so native window listeners fire
|
|
129
|
-
// (e.g. editVertexMode.onButtonClick for delete/undo in edit_vertex mode).
|
|
130
|
-
// Marked with _fromKeyboardActivation so handleItemClick ignores it and only the
|
|
131
|
-
// window listener handles it — preventing double-activation.
|
|
132
|
-
if (e.nativeEvent instanceof KeyboardEvent) {
|
|
133
|
-
const el = document.getElementById(`${id}-${stringToKebab(item.id)}`)
|
|
134
|
-
if (el) {
|
|
135
|
-
const click = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
136
|
-
click._fromKeyboardActivation = true
|
|
137
|
-
el.dispatchEvent(click)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Helper: Handle Enter key press.
|
|
144
|
-
* Closes menu, returns focus to instigator, then activates the selected item if enabled.
|
|
145
|
-
* @param {KeyboardEvent} e
|
|
146
|
-
* The Enter keydown event
|
|
147
|
-
*/
|
|
148
|
-
const handleEnter = (e) => {
|
|
149
|
-
e.preventDefault()
|
|
150
|
-
const item = items[index]
|
|
151
|
-
if (item && !disabledButtons.has(item.id)) {
|
|
152
|
-
activateItem(e, item)
|
|
153
|
-
}
|
|
154
|
-
instigator.focus()
|
|
155
|
-
setIsOpen(false)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Helper: Handle Space key press.
|
|
160
|
-
* For menuitemcheckbox: activates item only (menu stays open).
|
|
161
|
-
* For menuitem: closes menu, returns focus to instigator, then activates item.
|
|
162
|
-
* @param {KeyboardEvent} e
|
|
163
|
-
* The Space keydown event
|
|
164
|
-
*/
|
|
165
|
-
const handleSpace = (e) => {
|
|
166
|
-
e.preventDefault()
|
|
167
|
-
const item = items[index]
|
|
168
|
-
if (!item || disabledButtons.has(item.id)) {
|
|
169
|
-
return
|
|
170
|
-
}
|
|
171
|
-
const isCheckbox = item.isPressed !== undefined || item.pressedWhen
|
|
172
|
-
activateItem(e, item)
|
|
173
|
-
if (!isCheckbox) {
|
|
174
|
-
instigator.focus()
|
|
175
|
-
setIsOpen(false)
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Main keyboard handler for the menu.
|
|
181
|
-
* Dispatches to helpers or directly handles:
|
|
182
|
-
* - Escape/Esc: close & focus instigator
|
|
183
|
-
* - Tab: close & focus instigator (without preventDefault)
|
|
184
|
-
* - ArrowDown/ArrowUp: navigate visible items
|
|
185
|
-
* - Home: select first visible
|
|
186
|
-
* - End: select last visible
|
|
187
|
-
* - Enter: call selected item's onClick & close
|
|
188
|
-
* @param {KeyboardEvent} e
|
|
189
|
-
* Keyboard event from menu onKeyDown
|
|
190
|
-
*/
|
|
191
|
-
const handleMenuKeyDown = (e) => {
|
|
192
|
-
if (['Escape', 'Esc'].includes(e.key)) {
|
|
193
|
-
closeAndFocus(e, true)
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
if (e.key === 'Tab') {
|
|
197
|
-
closeAndFocus(e)
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
|
|
201
|
-
navigateVisible(e)
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
|
-
if (e.key === 'Home' && visibleIndices.length) {
|
|
205
|
-
setIndex(visibleIndices[0])
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
if (e.key === 'End' && visibleIndices.length) {
|
|
209
|
-
setIndex(visibleIndices[visibleIndices.length - 1])
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
if (e.key === 'Enter') {
|
|
213
|
-
handleEnter(e)
|
|
214
|
-
}
|
|
215
|
-
if (e.key === ' ') {
|
|
216
|
-
handleSpace(e)
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Helper: Close menu if click/focus originated outside menu and instigator.
|
|
222
|
-
* Registered on document focusin and pointerdown events to detect outside interactions.
|
|
223
|
-
* @param {Event} e
|
|
224
|
-
* The focusin or pointerdown event
|
|
225
|
-
*/
|
|
226
|
-
const handleOutside = (e) => {
|
|
227
|
-
if (menuRef.current?.contains(e.target) || buttonRefs.current[instigatorKey]?.contains(e.target)) {
|
|
228
|
-
return
|
|
229
|
-
}
|
|
230
|
-
setIsOpen(false)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Helper: Handle item click.
|
|
235
|
-
* Closes menu and activates the item; does nothing if the item is disabled.
|
|
236
|
-
* @param {React.MouseEvent} e
|
|
237
|
-
* React synthetic event from the LI click
|
|
238
|
-
* @param {Object} item
|
|
239
|
-
* The clicked item object with {id, label, onClick, ...}
|
|
240
|
-
*/
|
|
241
|
-
const handleItemClick = (e, item) => {
|
|
242
|
-
if (e.nativeEvent._fromKeyboardActivation) {
|
|
243
|
-
return
|
|
244
|
-
}
|
|
245
|
-
if (disabledButtons.has(item.id)) {
|
|
246
|
-
return
|
|
247
|
-
}
|
|
248
|
-
setIsOpen(false)
|
|
249
|
-
activateItem(e, item)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
useEffect(() => {
|
|
253
|
-
menuRef.current?.focus()
|
|
254
|
-
|
|
255
|
-
// If startPos changes on mount, ensure selection respects visible items.
|
|
256
|
-
if (startPos === 'first') {
|
|
257
|
-
setIndex(visibleIndices[0] ?? -1)
|
|
258
|
-
} else if (startPos === 'last') {
|
|
259
|
-
setIndex(visibleIndices[visibleIndices.length - 1] ?? -1)
|
|
260
|
-
} else {
|
|
261
|
-
// No action
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
document.addEventListener('focusin', handleOutside)
|
|
265
|
-
document.addEventListener('pointerdown', handleOutside)
|
|
266
|
-
|
|
267
|
-
return () => {
|
|
268
|
-
document.removeEventListener('focusin', handleOutside)
|
|
269
|
-
document.removeEventListener('pointerdown', handleOutside)
|
|
270
|
-
}
|
|
271
|
-
}, [])
|
|
272
|
-
|
|
273
|
-
return (
|
|
49
|
+
return createPortal(
|
|
274
50
|
<ul // NOSONAR
|
|
275
51
|
ref={menuRef}
|
|
276
52
|
id={popupMenuId}
|
|
277
53
|
className='im-c-popup-menu'
|
|
54
|
+
data-direction={menuDirection}
|
|
55
|
+
data-halign={menuHAlign}
|
|
56
|
+
style={menuStyle}
|
|
278
57
|
role='menu' // NOSONAR
|
|
279
58
|
tabIndex='-1'
|
|
280
59
|
aria-labelledby={instigatorKey}
|
|
@@ -282,20 +61,18 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
|
|
|
282
61
|
onKeyDown={handleMenuKeyDown}
|
|
283
62
|
>
|
|
284
63
|
{items.map((item, i) => (
|
|
285
|
-
<
|
|
64
|
+
<MenuItem
|
|
286
65
|
key={item.id}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
{(item.iconId || item.iconSvgContent) && <Icon id={item.iconId} svgContent={item.iconSvgContent} />}
|
|
296
|
-
<span className='im-c-popup-menu__item-label'>{item.label}</span>
|
|
297
|
-
</li>
|
|
66
|
+
item={item}
|
|
67
|
+
isSelected={index === i}
|
|
68
|
+
hiddenButtons={hiddenButtons}
|
|
69
|
+
disabledButtons={disabledButtons}
|
|
70
|
+
pressedButtons={pressedButtons}
|
|
71
|
+
id={id}
|
|
72
|
+
onItemClick={handleItemClick}
|
|
73
|
+
/>
|
|
298
74
|
))}
|
|
299
|
-
</ul
|
|
75
|
+
</ul>,
|
|
76
|
+
layoutRefs?.appContainerRef?.current ?? document.body
|
|
300
77
|
)
|
|
301
78
|
}
|
|
@@ -13,10 +13,23 @@
|
|
|
13
13
|
);
|
|
14
14
|
|
|
15
15
|
background-color: var(--background-color);
|
|
16
|
-
position:
|
|
16
|
+
position: fixed;
|
|
17
|
+
z-index: 9999;
|
|
17
18
|
list-style: none;
|
|
18
19
|
margin: 0;
|
|
19
20
|
padding: 0;
|
|
21
|
+
|
|
22
|
+
&[data-direction="below"] {
|
|
23
|
+
margin-top: var(--divider-gap);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
&[data-direction="above"] {
|
|
27
|
+
margin-bottom: var(--divider-gap);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&[data-halign="center"] {
|
|
31
|
+
transform: translateX(-50%);
|
|
32
|
+
}
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
// 2. Elements
|
|
@@ -125,9 +138,3 @@
|
|
|
125
138
|
opacity: 1;
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
// 3. Modifiers
|
|
129
|
-
.im-o-app__actions .im-c-popup-menu {
|
|
130
|
-
left: 50%;
|
|
131
|
-
transform: translateX(-50%);
|
|
132
|
-
bottom: calc(100% + (var(--primary-gap) * 2));
|
|
133
|
-
}
|
|
@@ -22,7 +22,8 @@ const mockUseApp = {
|
|
|
22
22
|
buttonConfig: {},
|
|
23
23
|
hiddenButtons: new Set(),
|
|
24
24
|
disabledButtons: new Set(),
|
|
25
|
-
pressedButtons: new Set()
|
|
25
|
+
pressedButtons: new Set(),
|
|
26
|
+
layoutRefs: { appContainerRef: { current: document.body } }
|
|
26
27
|
}
|
|
27
28
|
jest.mock('../../store/appContext', () => ({
|
|
28
29
|
useApp: jest.fn(() => mockUseApp)
|
|
@@ -387,4 +388,72 @@ describe('PopupMenu', () => {
|
|
|
387
388
|
expect(mockSetIsOpen).not.toHaveBeenCalled()
|
|
388
389
|
})
|
|
389
390
|
})
|
|
391
|
+
|
|
392
|
+
describe('buttonRect positioning', () => {
|
|
393
|
+
beforeEach(() => {
|
|
394
|
+
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true })
|
|
395
|
+
Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true })
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('uses top and data-direction=below when button is in top half', () => {
|
|
399
|
+
renderMenu({ buttonRect: { top: 100, bottom: 140, left: 50, right: 100 } })
|
|
400
|
+
const menu = screen.getByRole('menu')
|
|
401
|
+
expect(menu).toHaveStyle({ top: '140px' })
|
|
402
|
+
expect(menu).toHaveAttribute('data-direction', 'below')
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('uses bottom and data-direction=above when button is in bottom half', () => {
|
|
406
|
+
renderMenu({ buttonRect: { top: 500, bottom: 540, left: 50, right: 100 } })
|
|
407
|
+
const menu = screen.getByRole('menu')
|
|
408
|
+
expect(menu).toHaveStyle({ bottom: '268px' }) // 768 - 500
|
|
409
|
+
expect(menu).toHaveAttribute('data-direction', 'above')
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('uses left and data-halign=left when button center is in left third', () => {
|
|
413
|
+
// centerX = (50+100)/2 = 75 < 1024/3 ≈ 341
|
|
414
|
+
renderMenu({ buttonRect: { top: 100, bottom: 140, left: 50, right: 100 } })
|
|
415
|
+
const menu = screen.getByRole('menu')
|
|
416
|
+
expect(menu).toHaveStyle({ left: '50px' })
|
|
417
|
+
expect(menu).toHaveAttribute('data-halign', 'left')
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('uses right and data-halign=right when button center is in right third', () => {
|
|
421
|
+
// centerX = (700+750)/2 = 725 > 1024*2/3 ≈ 683
|
|
422
|
+
renderMenu({ buttonRect: { top: 100, bottom: 140, left: 700, right: 750 } })
|
|
423
|
+
const menu = screen.getByRole('menu')
|
|
424
|
+
expect(menu).toHaveStyle({ right: '274px' }) // 1024 - 750
|
|
425
|
+
expect(menu).toHaveAttribute('data-halign', 'right')
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('uses button centerX and data-halign=center when button center is in middle third', () => {
|
|
429
|
+
// centerX = (400+450)/2 = 425, between 341 and 683
|
|
430
|
+
renderMenu({ buttonRect: { top: 100, bottom: 140, left: 400, right: 450 } })
|
|
431
|
+
const menu = screen.getByRole('menu')
|
|
432
|
+
expect(menu).toHaveStyle({ left: '425px' })
|
|
433
|
+
expect(menu).toHaveAttribute('data-halign', 'center')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('applies no inline style when buttonRect is not provided', () => {
|
|
437
|
+
renderMenu()
|
|
438
|
+
const style = screen.getByRole('menu').style
|
|
439
|
+
expect(style.top).toBe('')
|
|
440
|
+
expect(style.bottom).toBe('')
|
|
441
|
+
expect(style.left).toBe('')
|
|
442
|
+
expect(style.right).toBe('')
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('falls back to document.body when appContainerRef.current is null', () => {
|
|
447
|
+
const saved = mockUseApp.layoutRefs
|
|
448
|
+
mockUseApp.layoutRefs = { appContainerRef: { current: null } }
|
|
449
|
+
renderMenu()
|
|
450
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
451
|
+
mockUseApp.layoutRefs = saved
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('closes menu on window resize', () => {
|
|
455
|
+
renderMenu()
|
|
456
|
+
window.dispatchEvent(new Event('resize'))
|
|
457
|
+
expect(mockSetIsOpen).toHaveBeenCalledWith(false)
|
|
458
|
+
})
|
|
390
459
|
})
|