@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.
Files changed (138) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/keyboard-shortcut-definition.md +102 -0
  7. package/docs/api.md +2 -0
  8. package/docs/building-a-plugin.md +1 -0
  9. package/docs/plugins/draw.md +1 -1
  10. package/docs/plugins/plugin-manifest.md +9 -0
  11. package/package.json +1 -1
  12. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  13. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  14. package/plugins/beta/frame/src/Frame.jsx +28 -2
  15. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  16. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  17. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  19. package/plugins/datasets/src/registry/dataset.js +8 -12
  20. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  21. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  22. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  23. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  24. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  25. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/src/DrawInit.jsx +23 -13
  29. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +15 -0
  30. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +18 -0
  31. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +9 -0
  32. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +12 -1
  33. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +18 -0
  34. package/plugins/draw/src/manifest.js +4 -1
  35. package/plugins/draw/src/manifest.test.js +19 -7
  36. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  37. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  38. package/plugins/interact/dist/umd/index.js +1 -1
  39. package/plugins/interact/src/events.js +6 -2
  40. package/plugins/interact/src/events.test.js +32 -0
  41. package/plugins/interact/src/hooks/useCrossHairVisibility.js +8 -3
  42. package/plugins/interact/src/hooks/useMapItemList.js +41 -12
  43. package/plugins/interact/src/hooks/useMapItemList.test.js +68 -3
  44. package/plugins/interact/src/utils/spatial.js +24 -1
  45. package/plugins/interact/src/utils/spatial.test.js +26 -1
  46. package/plugins/map-key/dist/css/index.css +1 -1
  47. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  48. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  49. package/plugins/map-key/dist/umd/index.js +1 -1
  50. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  51. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  52. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  53. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  54. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  55. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  56. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  57. package/plugins/map-key/src/manifest.js +1 -1
  58. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  59. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  60. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  61. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  62. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  63. package/plugins/menu/dist/umd/index.js +1 -1
  64. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  65. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  66. package/plugins/menu/src/initialise/createMenu.js +3 -1
  67. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  68. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  69. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  70. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  71. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  72. package/plugins/menu/src/reducers/pluginState.js +1 -6
  73. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  74. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  75. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  76. package/plugins/search/src/manifest.js +5 -9
  77. package/plugins/search/src/manifest.test.js +5 -0
  78. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  79. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  80. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  81. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  82. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  83. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  84. package/providers/maplibre/src/defaults.test.js +26 -0
  85. package/src/App/components/CrossHair/CrossHair.jsx +19 -11
  86. package/src/App/components/CrossHair/CrossHair.module.scss +33 -12
  87. package/src/App/components/CrossHair/CrossHair.test.jsx +35 -11
  88. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  89. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  90. package/src/App/components/MapButton/MapButton.jsx +19 -7
  91. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  92. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  93. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  94. package/src/App/components/Panel/Panel.jsx +19 -20
  95. package/src/App/components/Panel/Panel.module.scss +5 -0
  96. package/src/App/components/Panel/Panel.test.jsx +69 -4
  97. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  98. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  99. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  100. package/src/App/components/Viewport/Features.jsx +18 -4
  101. package/src/App/components/Viewport/Features.module.scss +4 -4
  102. package/src/App/components/Viewport/Features.test.jsx +155 -11
  103. package/src/App/components/Viewport/Viewport.jsx +3 -3
  104. package/src/App/controls/keyboardMappings.js +7 -2
  105. package/src/App/controls/keyboardMappings.test.js +21 -0
  106. package/src/App/controls/keyboardShortcuts.js +16 -2
  107. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  108. package/src/App/hooks/useCrossHairAPI.js +29 -1
  109. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  110. package/src/App/hooks/useFeatureFocus.js +86 -28
  111. package/src/App/hooks/useFeatureFocus.test.js +167 -0
  112. package/src/App/hooks/useFeatureItems.js +1 -1
  113. package/src/App/hooks/useInterfaceAPI.js +2 -1
  114. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  115. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  116. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  117. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  118. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  119. package/src/App/layout/Layout.jsx +4 -2
  120. package/src/App/layout/Layout.test.jsx +21 -0
  121. package/src/App/layout/layout.module.scss +7 -1
  122. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  123. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  124. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  125. package/src/App/renderer/mapButtons.js +4 -0
  126. package/src/App/renderer/mapButtons.test.js +20 -0
  127. package/src/App/renderer/mapPanels.js +33 -53
  128. package/src/App/renderer/mapPanels.test.js +29 -4
  129. package/src/App/renderer/slotHelpers.js +33 -0
  130. package/src/App/renderer/slotHelpers.test.js +79 -2
  131. package/src/InteractiveMap/InteractiveMap.js +13 -2
  132. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  133. package/src/config/appConfig.js +2 -2
  134. package/src/types.js +40 -0
  135. package/src/utils/getPanelElementId.js +4 -0
  136. package/src/utils/getPanelElementId.test.js +11 -0
  137. package/src/utils/getPanelRole.js +22 -0
  138. package/src/utils/getPanelRole.test.js +56 -0
@@ -23,7 +23,8 @@ jest.mock('../../renderer/SlotRenderer', () => ({
23
23
  }))
24
24
 
25
25
  jest.mock('../PopupMenu/PopupMenu', () => ({
26
- PopupMenu: ({ startPos, items, buttonRect }) => {
26
+ // Mirrors the real component: always mounted, hidden/children gated by isOpen.
27
+ PopupMenu: ({ popupMenuId, isOpen, startPos, items, buttonRect }) => {
27
28
  let selectedIndex = -1
28
29
  if (startPos === 'first' && items?.length > 0) {
29
30
  selectedIndex = 0
@@ -31,7 +32,11 @@ jest.mock('../PopupMenu/PopupMenu', () => ({
31
32
  if (startPos === 'last' && items?.length > 0) {
32
33
  selectedIndex = items.length - 1
33
34
  }
34
- return <div data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)} data-has-rect={String(!!buttonRect)}>{items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}</div>
35
+ return (
36
+ <div id={popupMenuId} hidden={!isOpen} data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)} data-has-rect={String(!!buttonRect)}>
37
+ {isOpen && items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}
38
+ </div>
39
+ )
35
40
  }
36
41
  }))
37
42
 
@@ -78,9 +83,20 @@ describe('MapButton', () => {
78
83
  expect(button).toHaveAttribute('aria-expanded', 'false')
79
84
  expect(button).not.toHaveAttribute('aria-pressed')
80
85
  expect(button).toHaveAttribute('aria-controls', 'prefix-panel-settings')
86
+ expect(button).not.toHaveAttribute('aria-haspopup')
81
87
  expect(screen.getByTestId('slot')).toHaveAttribute('data-slot', 'test-button')
82
88
  })
83
89
 
90
+ it('sets aria-haspopup="dialog" when the controlled panel is a dialog', () => {
91
+ renderButton({ panelId: 'Settings', idPrefix: 'prefix', panelRole: 'dialog' })
92
+ expect(getButton()).toHaveAttribute('aria-haspopup', 'dialog')
93
+ })
94
+
95
+ it.each(['complementary', 'region'])('sets no aria-haspopup when the controlled panel role is %s', (panelRole) => {
96
+ renderButton({ panelId: 'Settings', idPrefix: 'prefix', panelRole })
97
+ expect(getButton()).not.toHaveAttribute('aria-haspopup')
98
+ })
99
+
84
100
  it.each([
85
101
  [{ isPressed: true }, 'true'],
86
102
  [{ isPressed: 'true' }, undefined]
@@ -118,7 +134,7 @@ describe('MapButton', () => {
118
134
  renderButton({ isDisabled: true, menuItems: [{ label: 'Item' }], onClick })
119
135
  fireEvent.click(getButton())
120
136
  expect(onClick).not.toHaveBeenCalled()
121
- expect(screen.queryByTestId('popup-menu')).not.toBeInTheDocument()
137
+ expect(screen.getByTestId('popup-menu')).not.toBeVisible()
122
138
  })
123
139
 
124
140
  it('stores button refs correctly', () => {
@@ -138,6 +154,28 @@ describe('MapButton', () => {
138
154
  expect(button).toHaveAttribute('aria-haspopup', 'menu')
139
155
  })
140
156
 
157
+ it('reserves the aria-controls id as a stable, hidden node before the popup has ever been opened', () => {
158
+ renderButton({ idPrefix: 'prefix', menuItems: [{ label: 'Item' }] })
159
+ const menu = document.getElementById('prefix-popup-test')
160
+ expect(menu).toBeInTheDocument()
161
+ expect(menu).not.toBeVisible()
162
+ })
163
+
164
+ it('keeps the same DOM node (no swap/remount) when opening and closing the popup', () => {
165
+ renderButton({ idPrefix: 'prefix', menuItems: [{ label: 'Item' }] })
166
+ const menuBeforeOpen = document.getElementById('prefix-popup-test')
167
+
168
+ fireEvent.click(getButton())
169
+ const menuWhileOpen = document.getElementById('prefix-popup-test')
170
+ expect(menuWhileOpen).toBe(menuBeforeOpen)
171
+ expect(menuWhileOpen).toBeVisible()
172
+
173
+ fireEvent.click(getButton())
174
+ const menuAfterClose = document.getElementById('prefix-popup-test')
175
+ expect(menuAfterClose).toBe(menuBeforeOpen)
176
+ expect(menuAfterClose).not.toBeVisible()
177
+ })
178
+
141
179
  it('toggles popup aria-expanded and startPos', () => {
142
180
  renderButton({ menuItems: [{ label: 'Item' }] })
143
181
  const button = getButton()
@@ -224,7 +262,7 @@ describe('MapButton', () => {
224
262
  it('does nothing for unrelated keys', () => {
225
263
  renderButton({ menuItems: [{ label: 'Item' }] })
226
264
  fireEvent.keyUp(getButton(), { key: 'Enter' })
227
- expect(screen.queryByTestId('popup-menu')).not.toBeInTheDocument()
265
+ expect(screen.getByTestId('popup-menu')).not.toBeVisible()
228
266
  })
229
267
 
230
268
  it.each([
@@ -3,7 +3,7 @@ import { stringToKebab } from '../../../utils/stringToKebab.js'
3
3
  const SymbolMarker = ({ marker, mapId, markerRef, isSelected, symbolProps }) => { // NOSONAR: project does not use PropTypes
4
4
  const { resolvedSvg, viewBox, anchor, shapeId, scaledWidth, scaledHeight } = symbolProps
5
5
  return (
6
- <svg // NOSONAR: role='img' with aria-label is the correct ARIA pattern for SVG icons; <img> is not appropriate here
6
+ <svg
7
7
  ref={markerRef(marker.id)}
8
8
  id={`${mapId}-marker-${marker.id}`}
9
9
  className={[
@@ -15,8 +15,7 @@ const SymbolMarker = ({ marker, mapId, markerRef, isSelected, symbolProps }) =>
15
15
  height={scaledHeight}
16
16
  viewBox={viewBox}
17
17
  overflow='visible'
18
- role='img'
19
- aria-label={marker.label || 'Map marker'}
18
+ aria-hidden='true'
20
19
  style={{
21
20
  display: marker.isVisible ? 'block' : 'none',
22
21
  marginLeft: `${-anchor[0] * scaledWidth}px`,
@@ -19,19 +19,18 @@ describe('SymbolMarker', () => {
19
19
  expect(container.querySelector('svg').getAttribute('id')).toBe('my-map-marker-marker-1')
20
20
  })
21
21
 
22
- it('has role img', () => {
22
+ // Decorative: the ancestor markers layer is aria-hidden, and Features (the accessible
23
+ // features listbox) is the sole accessible surface for a marker's label — see Features.jsx.
24
+ it('is aria-hidden', () => {
23
25
  const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
24
- expect(container.querySelector('svg').getAttribute('role')).toBe('img')
26
+ expect(container.querySelector('svg').getAttribute('aria-hidden')).toBe('true')
25
27
  })
26
28
 
27
- it('aria-label uses marker.label', () => {
29
+ it('has no role or aria-label, regardless of marker.label', () => {
28
30
  const { container } = render(<SymbolMarker marker={makeMarker({ label: 'My location' })} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
29
- expect(container.querySelector('svg').getAttribute('aria-label')).toBe('My location')
30
- })
31
-
32
- it('aria-label falls back to Map marker when no label', () => {
33
- const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
34
- expect(container.querySelector('svg').getAttribute('aria-label')).toBe('Map marker')
31
+ const svg = container.querySelector('svg')
32
+ expect(svg.getAttribute('role')).toBeNull()
33
+ expect(svg.getAttribute('aria-label')).toBeNull()
35
34
  })
36
35
 
37
36
  it('applies shapeId class', () => {
@@ -2,31 +2,20 @@ import React, { useRef, useEffect, useMemo } from 'react'
2
2
  import { useConfig } from '../../store/configContext'
3
3
  import { useApp } from '../../store/appContext'
4
4
  import { stringToKebab } from '../../../utils/stringToKebab.js'
5
+ import { getPanelElementId } from '../../../utils/getPanelElementId.js'
5
6
  import { useModalPanelBehaviour } from '../../hooks/useModalPanelBehaviour.js'
6
7
  import { useIsScrollable } from '../../hooks/useIsScrollable.js'
8
+ import { classifyPanel, getPanelRole } from '../../../utils/getPanelRole.js'
7
9
  import { Icon } from '../Icon/Icon'
8
10
  import { Tabs } from '../Tabs/Tabs.jsx'
9
11
 
10
12
  const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => {
11
- const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
12
- const isDialog = !isAside && bpConfig.dismissible
13
- const isModal = bpConfig.modal === true
14
- const isDismissible = bpConfig.dismissible !== false
13
+ const { isModal, isAside, isDismissible, isDialog } = classifyPanel(bpConfig)
15
14
  const shouldFocus = isModal || focusOnOpen === true || (focusOnOpen !== false && focus !== false && (focus === true || Boolean(triggeringElement)))
16
15
  const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
17
16
  return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl }
18
17
  }
19
18
 
20
- const getPanelRole = (isDialog, isDismissible) => {
21
- if (isDialog) {
22
- return 'dialog'
23
- }
24
- if (isDismissible) {
25
- return 'complementary'
26
- }
27
- return 'region'
28
- }
29
-
30
19
  const buildPanelClassNames = (slot, showLabel) => [
31
20
  'im-c-panel',
32
21
  `im-c-panel--${slot}`,
@@ -38,15 +27,17 @@ const buildPanelBodyClassNames = (showLabel, isDismissible) => [
38
27
  !showLabel && isDismissible && 'im-c-panel__body--offset'
39
28
  ].filter(Boolean).join(' ')
40
29
 
41
- const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot }) => ({
30
+ const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot, isOpen }) => ({
42
31
  id: elementId,
43
32
  'aria-labelledby': `${elementId}-label`,
44
33
  tabIndex: shouldFocus ? -1 : undefined, // nosonar
45
- role: getPanelRole(isDialog, isDismissible),
34
+ role: getPanelRole({ isDialog, isDismissible }),
46
35
  'aria-modal': isDialog && isModal ? 'true' : undefined,
47
36
  style: width ? { width } : undefined,
48
37
  className: panelClass,
49
- 'data-slot': slot
38
+ 'data-slot': slot,
39
+ // Panel is mounted permanently (see mapPanels.js); hidden is what actually opens/closes it.
40
+ hidden: !isOpen
50
41
  })
51
42
 
52
43
  const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }) => ({
@@ -112,7 +103,7 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
112
103
 
113
104
  const rootEl = document.getElementById(`${id}-im-app`)
114
105
  const bpConfig = panelConfig[breakpoint]
115
- const elementId = `${id}-panel-${stringToKebab(panelId)}`
106
+ const elementId = getPanelElementId(id, panelId)
116
107
 
117
108
  const { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement, panelConfig.focus, focusOnOpen) // nosonar
118
109
 
@@ -129,7 +120,15 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
129
120
  const panelRef = rootRef || internalPanelRef
130
121
 
131
122
  const handleClose = () => {
132
- requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.({ preventScroll: interfaceType !== 'keyboard' }) })
123
+ requestAnimationFrame(() => {
124
+ // Re-checked here, not at handleClose-call time — the triggering element (a button, a
125
+ // Features-list item, ...) may have been removed from the DOM while the panel was open
126
+ // (e.g. the map panned and the feature it came from dropped out of the current list).
127
+ // document.contains() on a detached node is false, not a throw, so this is a plain check.
128
+ const triggeringElement = props?.triggeringElement
129
+ const focusTarget = triggeringElement && document.contains(triggeringElement) ? triggeringElement : layoutRefs.viewportRef.current
130
+ focusTarget?.focus?.({ preventScroll: interfaceType !== 'keyboard' })
131
+ })
133
132
  dispatch({ type: 'CLOSE_PANEL', payload: panelId })
134
133
  }
135
134
 
@@ -149,7 +148,7 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
149
148
  const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissible)
150
149
  const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html])
151
150
 
152
- const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot })
151
+ const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot, isOpen })
153
152
  const bodyProps = buildBodyProps({ bodyRef, panelBodyClass, isBodyScrollable, elementId })
154
153
  // DOM anchor for controls DOM-projected via the JS/consumer-HTML API — see HtmlElementHost.jsx.
155
154
  // Only present on the items-capable body below: dangerouslySetInnerHTML owns the static-html
@@ -20,6 +20,11 @@
20
20
  pointer-events: auto;
21
21
  color: var(--foreground-color);
22
22
  background-color: var(--background-color);
23
+
24
+ // Override needed: display: flex above beats the browser's default [hidden] rule otherwise.
25
+ &[hidden] {
26
+ display: none;
27
+ }
23
28
  }
24
29
 
25
30
  // 2. Elements
@@ -102,6 +102,20 @@ describe('Panel', () => {
102
102
  expect(dialog).toHaveAttribute('aria-modal', 'true')
103
103
  expect(dialog).toHaveAttribute('tabIndex', '-1')
104
104
  })
105
+
106
+ it('renders dialog role and aria-modal for a modal panel even when dismissible is left unset (WCAG requires a modal to be dismissible)', () => {
107
+ renderPanel({ desktop: { slot: 'overlay', modal: true } })
108
+ const dialog = screen.getByRole('dialog')
109
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
110
+ expect(screen.getByRole('button', { name: 'Close Settings' })).toBeInTheDocument()
111
+ })
112
+
113
+ it('renders dialog role and aria-modal for a modal panel even when dismissible: false is set (a non-dismissible modal would trap keyboard/AT users)', () => {
114
+ renderPanel({ desktop: { slot: 'overlay', modal: true, dismissible: false } })
115
+ const dialog = screen.getByRole('dialog')
116
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
117
+ expect(screen.getByRole('button', { name: 'Close Settings' })).toBeInTheDocument()
118
+ })
105
119
  })
106
120
 
107
121
  describe('focus behaviour', () => {
@@ -142,9 +156,18 @@ describe('Panel', () => {
142
156
  })
143
157
 
144
158
  describe('close functionality', () => {
159
+ // document.contains() needs a real Node — a plain { focus: jest.fn() } mock object throws
160
+ // ("parameter 1 is not of type 'Node'"), so triggeringElement here is a real element, spied
161
+ // on and attached to the document, matching what production always passes
162
+ // (e.currentTarget / document.activeElement).
163
+ const makeAttachedTriggeringElement = () => {
164
+ const el = document.createElement('button')
165
+ document.body.appendChild(el)
166
+ return { el, focusMock: jest.spyOn(el, 'focus') }
167
+ }
168
+
145
169
  it('focuses triggeringElement on close for button slots', () => {
146
- const focusMock = jest.fn()
147
- const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
170
+ const { el: triggeringElement, focusMock } = makeAttachedTriggeringElement()
148
171
 
149
172
  renderPanel(
150
173
  { desktop: { slot: 'top-button', dismissible: true, open: false } },
@@ -157,8 +180,7 @@ describe('Panel', () => {
157
180
  })
158
181
 
159
182
  it('handles close for non-button slots', () => {
160
- const focusMock = jest.fn()
161
- const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
183
+ const { el: triggeringElement, focusMock } = makeAttachedTriggeringElement()
162
184
 
163
185
  renderPanel(
164
186
  { desktop: { slot: 'overlay', dismissible: true, modal: true } },
@@ -176,6 +198,20 @@ describe('Panel', () => {
176
198
  expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
177
199
  expect(dispatch).toHaveBeenCalledWith({ type: 'CLOSE_PANEL', payload: 'Settings' })
178
200
  })
201
+
202
+ it('falls back to viewportRef focus when triggeringElement has been removed from the DOM', () => {
203
+ const triggeringElement = document.createElement('button') // never attached
204
+ const focusMock = jest.spyOn(triggeringElement, 'focus')
205
+
206
+ renderPanel(
207
+ { desktop: { slot: 'side', dismissible: true, open: false } },
208
+ { props: { triggeringElement } }
209
+ )
210
+
211
+ fireEvent.click(screen.getByRole('button', { name: 'Close Settings' }))
212
+ expect(focusMock).not.toHaveBeenCalled()
213
+ expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
214
+ })
179
215
  })
180
216
 
181
217
  describe('content rendering', () => {
@@ -308,4 +344,33 @@ describe('Panel', () => {
308
344
  })
309
345
  })
310
346
  })
347
+
348
+ describe('isOpen (always-mounted, hidden-toggle)', () => {
349
+ it('renders hidden (not unmounted) when isOpen is false, so its id stays a real element', () => {
350
+ renderPanel({}, { isOpen: false })
351
+ const panel = document.getElementById('app-panel-settings')
352
+ expect(panel).toBeInTheDocument()
353
+ expect(panel).toHaveAttribute('hidden')
354
+ })
355
+
356
+ it('does not focus a closed panel, even one that would otherwise auto-focus (modal)', () => {
357
+ renderPanel({ desktop: { slot: 'overlay', dismissible: true, modal: true } }, { isOpen: false })
358
+ const panel = document.getElementById('app-panel-settings')
359
+ expect(document.activeElement).not.toBe(panel)
360
+ })
361
+
362
+ it('keeps the same DOM node (no swap/remount) across an isOpen transition, and un-hides it once open', () => {
363
+ const panelConfig = { desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: true } }
364
+ const { rerender } = render(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' isOpen={false} />)
365
+ const panelWhileClosed = document.getElementById('app-panel-settings')
366
+ expect(panelWhileClosed).toHaveAttribute('hidden')
367
+
368
+ rerender(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' isOpen items={[{ id: 'a', element: <p>Item</p> }]} />)
369
+
370
+ const panelWhileOpen = document.getElementById('app-panel-settings')
371
+ expect(panelWhileOpen).toBe(panelWhileClosed)
372
+ expect(panelWhileOpen).not.toHaveAttribute('hidden')
373
+ expect(screen.getByText('Item')).toBeInTheDocument()
374
+ })
375
+ })
311
376
  })
@@ -21,7 +21,8 @@ const MenuItem = ({ item, isSelected, hiddenButtons, disabledButtons, pressedBut
21
21
  </li>
22
22
  )
23
23
 
24
- export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect }) => {
24
+ // isOpen defaults to true so direct/standalone usage doesn't need to pass it.
25
+ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect, isOpen = true }) => {
25
26
  const { id } = useConfig()
26
27
  const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons, layoutRefs } = useApp()
27
28
  const instigatorKey = buttonId ?? instigatorId
@@ -43,7 +44,8 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
43
44
  id,
44
45
  menuRef,
45
46
  setIsOpen,
46
- buttonRect
47
+ buttonRect,
48
+ isOpen
47
49
  })
48
50
 
49
51
  return createPortal(
@@ -56,11 +58,13 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
56
58
  style={menuStyle}
57
59
  role='menu' // NOSONAR
58
60
  tabIndex='-1'
61
+ hidden={!isOpen}
59
62
  aria-labelledby={instigatorKey}
60
63
  aria-activedescendant={index >= 0 ? `${id}-${stringToKebab(items[index].id)}` : undefined}
61
64
  onKeyDown={handleMenuKeyDown}
62
65
  >
63
- {items.map((item, i) => (
66
+ {/* Items only mount once open — the <ul> above stays in the DOM either way. */}
67
+ {isOpen && items.map((item, i) => (
64
68
  <MenuItem
65
69
  key={item.id}
66
70
  item={item}
@@ -651,4 +651,75 @@ describe('PopupMenu', () => {
651
651
  window.dispatchEvent(new Event('resize'))
652
652
  expect(mockSetIsOpen).toHaveBeenCalledWith(false)
653
653
  })
654
+
655
+ describe('isOpen (always-mounted, hidden-toggle)', () => {
656
+ it('renders a hidden, empty menu when isOpen is false, without grabbing focus or item content', () => {
657
+ renderMenu({ isOpen: false })
658
+ const ul = screen.getByRole('menu', { hidden: true })
659
+ expect(ul).toHaveAttribute('hidden')
660
+ expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
661
+ expect(document.activeElement).not.toBe(ul)
662
+ })
663
+
664
+ it('keeps the same ul node across an isOpen transition (no swap/remount) and un-hides it once open', () => {
665
+ const { rerender } = renderMenu({ isOpen: false })
666
+ const ulWhileClosed = screen.getByRole('menu', { hidden: true })
667
+
668
+ rerender(
669
+ <PopupMenu
670
+ id='menu'
671
+ pluginId='plugin1'
672
+ instigatorId='instigator'
673
+ startPos='first'
674
+ menuRef={menuRef}
675
+ items={items}
676
+ setIsOpen={mockSetIsOpen}
677
+ isOpen
678
+ />
679
+ )
680
+
681
+ const ulWhileOpen = screen.getByRole('menu')
682
+ expect(ulWhileOpen).toBe(ulWhileClosed)
683
+ expect(ulWhileOpen).not.toHaveAttribute('hidden')
684
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
685
+ expect(document.activeElement).toBe(ulWhileOpen)
686
+ })
687
+
688
+ it('resets selection on every open rather than carrying over a stale index from a previous open', () => {
689
+ const { rerender } = renderMenu({ startPos: 'first', startIndex: undefined })
690
+ expectSelected('Item 1')
691
+
692
+ // Close, then reopen via a plain click — selection should reset, not carry over.
693
+ rerender(
694
+ <PopupMenu
695
+ id='menu'
696
+ pluginId='plugin1'
697
+ instigatorId='instigator'
698
+ menuRef={menuRef}
699
+ items={items}
700
+ setIsOpen={mockSetIsOpen}
701
+ isOpen={false}
702
+ />
703
+ )
704
+ rerender(
705
+ <PopupMenu
706
+ id='menu'
707
+ pluginId='plugin1'
708
+ instigatorId='instigator'
709
+ menuRef={menuRef}
710
+ items={items}
711
+ setIsOpen={mockSetIsOpen}
712
+ isOpen
713
+ />
714
+ )
715
+ expectNotSelected('Item 1')
716
+ expectNotSelected('Item 2')
717
+ })
718
+
719
+ it('defaults isOpen to true for direct/standalone usage', () => {
720
+ renderMenu()
721
+ expect(screen.getByRole('menu')).not.toHaveAttribute('hidden')
722
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
723
+ })
724
+ })
654
725
  })
@@ -275,12 +275,14 @@ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disa
275
275
  * @param {object} params.menuRef - Ref to the menu UL element.
276
276
  * @param {Function} params.setIsOpen - Callback to open/close the menu.
277
277
  * @param {DOMRect} params.buttonRect - Bounding rect of the trigger button for positioning.
278
+ * @param {boolean} [params.isOpen] - Whether the menu is currently open (PopupMenu stays
279
+ * mounted; this just toggles visibility, so setup/teardown here is keyed on isOpen, not mount).
278
280
  * @returns {{ index: number, handleMenuKeyDown: Function, handleItemClick: Function,
279
281
  * menuStyle: object, menuDirection: string, menuHAlign: string }}
280
282
  */
281
283
  export const usePopupMenu = ({
282
284
  items, hiddenButtons, startIndex, startPos, instigator, instigatorKey,
283
- buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect
285
+ buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect, isOpen
284
286
  }) => {
285
287
  const { dispatch, layoutRefs } = useApp()
286
288
  const viewportRef = layoutRefs.viewportRef
@@ -329,15 +331,14 @@ export const usePopupMenu = ({
329
331
  }
330
332
  }
331
333
 
334
+ // Keyed on isOpen (not mount) since PopupMenu stays mounted; resolveInitialIndex re-runs each
335
+ // open so selection resets instead of carrying over from a previous open.
332
336
  useEffect(() => {
333
- menuRef.current?.focus()
334
- if (startPos === 'first') {
335
- setIndex(visibleIndices[0] ?? -1)
336
- } else if (startPos === 'last') {
337
- setIndex(visibleIndices[visibleIndices.length - 1] ?? -1) // NOSONAR .length - 1 used instead of .at(-1) for wider browser support
338
- } else {
339
- // No action
337
+ if (!isOpen) {
338
+ return undefined
340
339
  }
340
+ menuRef.current?.focus()
341
+ setIndex(resolveInitialIndex(startIndex, startPos, visibleIndices))
341
342
  const handleResize = () => setIsOpen(false)
342
343
  document.addEventListener('focusin', handleOutside)
343
344
  document.addEventListener('pointerdown', handleOutside)
@@ -347,7 +348,7 @@ export const usePopupMenu = ({
347
348
  document.removeEventListener('pointerdown', handleOutside)
348
349
  window.removeEventListener('resize', handleResize)
349
350
  }
350
- }, [])
351
+ }, [isOpen])
351
352
 
352
353
  const { style: menuStyle, direction: menuDirection, halign: menuHAlign } = getMenuStyle(buttonRect)
353
354
  return { index, handleMenuKeyDown, handleItemClick, menuStyle, menuDirection, menuHAlign }
@@ -1,29 +1,43 @@
1
1
  import React, { forwardRef } from 'react'
2
2
  import { useConfig } from '../../store/configContext.js'
3
3
 
4
- export const Features = forwardRef(({ activeFeatureId, selectedIds = [], multiselectable = false, items = [], onFocus, onBlur }, ref) => {
4
+ export const Features = forwardRef(({ activeFeatureId, tabbableId, selectedIds = [], multiselectable = false, items = [], onFocus, onBlur, onSelectItem }, ref) => {
5
5
  const { id } = useConfig()
6
6
  const hasItems = items.length > 0
7
+ // Roving tabindex: exactly one option is a Tab stop at a time (the focused item while the
8
+ // list has real focus, otherwise the resting entry position) — see useFeatureFocus.js.
9
+ const currentId = activeFeatureId ?? tabbableId
7
10
  return (
8
11
  <ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
9
12
  id={`${id}-features`}
10
13
  ref={ref}
11
14
  role='listbox' // NOSONAR
12
- tabIndex={hasItems ? '0' : '-1'}
13
15
  aria-hidden={hasItems ? undefined : true}
14
16
  aria-label='Map features'
15
17
  aria-describedby={`${id}-keyboard-desc`}
16
18
  aria-multiselectable={multiselectable || undefined}
17
- aria-activedescendant={activeFeatureId ? `${id}-feature-${activeFeatureId}` : undefined}
18
19
  className='im-c-features'
19
20
  onFocus={onFocus}
20
21
  onBlur={onBlur}
21
22
  >
22
23
  {items.map(item => (
23
- <li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern
24
+ <li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern.
25
+ // tabIndex/onClick make each option a real, independently focusable and activatable
26
+ // control — required so touch screen readers and voice control can reach and operate
27
+ // it directly, not just app-managed keyboard-arrow navigation.
24
28
  key={item.id} id={`${id}-feature-${item.id}`} role='option' // NOSONAR
25
29
  data-id={item.id}
30
+ tabIndex={item.id === currentId ? 0 : -1}
26
31
  aria-selected={selectedIds.includes(item.id)}
32
+ // Positions the (visually clipped) option over its on-map feature — item.x/item.y are
33
+ // screen coordinates from the interact plugin (see useMapItemList.js). Without this,
34
+ // every option collapses to the CSS static-position default (~top-left of the
35
+ // viewport), which is invisible to sighted users but misaligns coordinate-based AT
36
+ // overlays, e.g. macOS Voice Control's "Show Numbers", which numbers items at their
37
+ // real position on screen. Items without a resolved position (not yet supported for
38
+ // dataset/polygon features) fall back to that same default.
39
+ style={item.x != null ? { left: item.x, top: item.y } : undefined}
40
+ onClick={() => onSelectItem?.(item.id)}
27
41
  >
28
42
  {item.label}
29
43
  </li>
@@ -16,10 +16,6 @@
16
16
  $is-inset: true
17
17
  );
18
18
 
19
- &[data-focus-visible="true"][aria-activedescendant]::after {
20
- opacity: 0;
21
- }
22
-
23
19
  [role="option"] {
24
20
  position: absolute;
25
21
  width: 1px;
@@ -27,5 +23,9 @@
27
23
  overflow: hidden;
28
24
  clip-path: inset(50%);
29
25
  white-space: nowrap;
26
+ // Roving tabindex moves real focus (and click activation) onto individual options, so they
27
+ // need pointer events re-enabled — overridden off .im-c-features's pointer-events: none,
28
+ // which otherwise lets mouse interaction pass through to the map everywhere else.
29
+ pointer-events: auto;
30
30
  }
31
31
  }