@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.
Files changed (202) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/index.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +8 -0
  7. package/package.json +3 -2
  8. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
  9. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
  10. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
  11. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
  12. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  13. package/plugins/beta/datasets/src/datasets.js +55 -2
  14. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
  15. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
  16. package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
  17. package/plugins/beta/datasets/src/mapLayers.js +41 -3
  18. package/plugins/beta/datasets/src/utils/bbox.js +113 -2
  19. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  23. package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
  24. package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
  25. package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
  26. package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
  27. package/plugins/beta/draw-ml/src/defaults.js +1 -0
  28. package/plugins/beta/draw-ml/src/draw.scss +23 -0
  29. package/plugins/beta/draw-ml/src/events.js +55 -113
  30. package/plugins/beta/draw-ml/src/manifest.js +64 -38
  31. package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
  32. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
  33. package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
  34. package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
  35. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
  36. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
  37. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
  38. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
  39. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
  40. package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
  41. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  42. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  43. package/plugins/beta/frame/src/Frame.jsx +1 -1
  44. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  45. package/plugins/beta/map-styles/dist/esm/index.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/map-styles/src/mapStyles.scss +4 -1
  49. package/plugins/interact/dist/css/index.css +1 -1
  50. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  51. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/index.js +1 -1
  53. package/plugins/interact/src/InteractInit.jsx +1 -1
  54. package/plugins/interact/src/defaults.js +1 -0
  55. package/plugins/interact/src/events.js +37 -52
  56. package/plugins/interact/src/events.test.js +35 -0
  57. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  58. package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
  59. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
  60. package/plugins/interact/src/interact.scss +6 -0
  61. package/plugins/interact/src/reducer.js +25 -27
  62. package/plugins/interact/src/reducer.test.js +19 -6
  63. package/plugins/interact/src/utils/featureQueries.js +2 -2
  64. package/plugins/interact/src/utils/spatial.js +95 -0
  65. package/plugins/interact/src/utils/spatial.test.js +93 -0
  66. package/plugins/search/dist/css/index.css +1 -1
  67. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  68. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  69. package/plugins/search/dist/umd/index.js +1 -1
  70. package/plugins/search/src/Search.jsx +2 -2
  71. package/plugins/search/src/components/Form/Form.jsx +1 -1
  72. package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
  73. package/plugins/search/src/datasets.js +1 -1
  74. package/plugins/search/src/defaults.js +3 -0
  75. package/plugins/search/src/events/fetchSuggestions.js +48 -53
  76. package/plugins/search/src/events/formHandlers.js +3 -2
  77. package/plugins/search/src/events/index.js +1 -1
  78. package/plugins/search/src/events/inputHandlers.js +3 -1
  79. package/plugins/search/src/events/suggestionHandlers.js +12 -3
  80. package/plugins/search/src/search.scss +4 -1
  81. package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
  82. package/providers/beta/esri/src/esriProvider.js +1 -1
  83. package/providers/beta/esri/src/index.js +1 -1
  84. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  85. package/providers/maplibre/dist/esm/index.js +1 -1
  86. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  87. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  88. package/providers/maplibre/dist/umd/index.js +1 -1
  89. package/providers/maplibre/src/defaults.js +3 -2
  90. package/providers/maplibre/src/index.js +22 -19
  91. package/providers/maplibre/src/maplibreProvider.js +13 -11
  92. package/providers/maplibre/src/utils/detectWebgl.js +1 -2
  93. package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
  94. package/providers/maplibre/src/utils/labels.js +174 -120
  95. package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
  96. package/providers/maplibre/src/utils/queryFeatures.js +146 -0
  97. package/providers/maplibre/src/utils/spatial.js +14 -17
  98. package/sonar-project.properties +39 -2
  99. package/src/App/components/Actions/Actions.jsx +2 -2
  100. package/src/App/components/Actions/Actions.module.scss +2 -2
  101. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
  102. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
  103. package/src/App/components/Logo/Logo.jsx +1 -1
  104. package/src/App/components/MapButton/MapButton.jsx +217 -41
  105. package/src/App/components/MapButton/MapButton.module.scss +32 -21
  106. package/src/App/components/MapButton/MapButton.test.jsx +128 -84
  107. package/src/App/components/Markers/Markers.jsx +1 -1
  108. package/src/App/components/Markers/Markers.module.scss +0 -5
  109. package/src/App/components/Panel/Panel.jsx +75 -45
  110. package/src/App/components/Panel/Panel.module.scss +18 -24
  111. package/src/App/components/Panel/Panel.test.jsx +18 -0
  112. package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
  113. package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
  114. package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
  115. package/src/App/components/Tooltip/Tooltip.jsx +4 -2
  116. package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
  117. package/src/App/components/Viewport/MapController.jsx +1 -1
  118. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  119. package/src/App/components/Viewport/Viewport.jsx +2 -2
  120. package/src/App/components/Viewport/Viewport.module.scss +2 -2
  121. package/src/App/controls/keyboardActions.js +3 -2
  122. package/src/App/controls/keyboardShortcuts.js +4 -2
  123. package/src/App/hooks/useButtonStateEvaluator.js +58 -45
  124. package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
  125. package/src/App/hooks/useCrossHairAPI.js +49 -47
  126. package/src/App/hooks/useFocusVisible.js +2 -2
  127. package/src/App/hooks/useInterfaceAPI.js +16 -4
  128. package/src/App/hooks/useKeyboardHint.js +1 -1
  129. package/src/App/hooks/useKeyboardShortcuts.js +4 -2
  130. package/src/App/hooks/useMapAnnouncements.js +34 -32
  131. package/src/App/hooks/useMapEvents.js +12 -1
  132. package/src/App/hooks/useMapProviderOverrides.js +3 -3
  133. package/src/App/hooks/useMapStateSync.js +7 -1
  134. package/src/App/hooks/useMapURLSync.js +6 -1
  135. package/src/App/hooks/useMarkersAPI.js +88 -50
  136. package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
  137. package/src/App/hooks/useModalPanelBehaviour.js +46 -40
  138. package/src/App/hooks/useResizeObserver.js +2 -2
  139. package/src/App/initialiseApp.js +45 -38
  140. package/src/App/layout/Layout.jsx +6 -4
  141. package/src/App/layout/layout.module.scss +13 -17
  142. package/src/App/registry/keyboardShortcutRegistry.js +12 -2
  143. package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
  144. package/src/App/registry/mergeManifests.js +1 -1
  145. package/src/App/registry/panelRegistry.js +1 -1
  146. package/src/App/registry/panelRegistry.test.js +30 -1
  147. package/src/App/registry/pluginRegistry.js +14 -51
  148. package/src/App/registry/pluginRegistry.test.js +54 -6
  149. package/src/App/renderer/HtmlElementHost.jsx +186 -0
  150. package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
  151. package/src/App/renderer/mapButtons.js +11 -8
  152. package/src/App/renderer/mapButtons.test.js +6 -4
  153. package/src/App/renderer/mapControls.js +6 -0
  154. package/src/App/renderer/mapControls.test.js +10 -2
  155. package/src/App/renderer/mapPanels.js +39 -26
  156. package/src/App/renderer/mapPanels.test.js +29 -0
  157. package/src/App/renderer/pluginWrapper.test.js +28 -0
  158. package/src/App/renderer/slotHelpers.js +60 -0
  159. package/src/App/renderer/slotHelpers.test.js +82 -0
  160. package/src/App/store/AppProvider.jsx +3 -0
  161. package/src/App/store/AppProvider.test.jsx +40 -0
  162. package/src/App/store/PluginProvider.jsx +7 -5
  163. package/src/App/store/appActionsMap.js +25 -1
  164. package/src/App/store/appActionsMap.test.js +21 -0
  165. package/src/App/store/appReducer.js +1 -0
  166. package/src/App/store/mapActionsMap.js +12 -24
  167. package/src/App/store/mapReducer.js +1 -1
  168. package/src/InteractiveMap/InteractiveMap.js +79 -6
  169. package/src/InteractiveMap/InteractiveMap.test.js +164 -2
  170. package/src/InteractiveMap/behaviourController.js +12 -2
  171. package/src/InteractiveMap/behaviourController.test.js +82 -1
  172. package/src/InteractiveMap/deviceChecker.js +1 -1
  173. package/src/InteractiveMap/deviceChecker.test.js +2 -2
  174. package/src/InteractiveMap/historyManager.js +41 -5
  175. package/src/InteractiveMap/historyManager.test.js +37 -6
  176. package/src/InteractiveMap/polyfills.js +39 -0
  177. package/src/InteractiveMap/polyfills.test.js +127 -0
  178. package/src/config/appConfig.js +5 -5
  179. package/src/config/appConfig.test.js +107 -42
  180. package/src/config/defaults.js +1 -0
  181. package/src/index.umd.js +1 -1
  182. package/src/scss/main.scss +1 -0
  183. package/src/scss/settings/_dimensions.scss +6 -2
  184. package/src/services/eventBus.test.js +24 -1
  185. package/src/test-utils.js +1 -0
  186. package/src/types.js +5 -0
  187. package/src/utils/detectBreakpoint.js +78 -77
  188. package/src/utils/detectBreakpoint.test.js +50 -4
  189. package/src/utils/detectInterfaceType.js +4 -4
  190. package/src/utils/getIsFullscreen.js +3 -1
  191. package/src/utils/getSafeZoneInset.js +23 -7
  192. package/src/utils/getSafeZoneInset.test.js +18 -0
  193. package/src/utils/mapStateSync.js +3 -3
  194. package/src/utils/queryString.js +1 -1
  195. package/src/utils/stringToKebab.js +1 -1
  196. package/src/utils/toggleInertElements.js +37 -12
  197. package/webpack.dev.mjs +3 -4
  198. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  199. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  200. package/plugins/search/src/utils/fetchDataset.js +0 -23
  201. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  202. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
@@ -3,55 +3,57 @@ import { render, screen, fireEvent } from '@testing-library/react'
3
3
  import { MapButton } from './MapButton'
4
4
 
5
5
  jest.mock('../../../utils/stringToKebab', () => ({
6
- stringToKebab: (str) => str ? str.replace(/\s+/g, '-').toLowerCase() : ''
6
+ stringToKebab: (str) => (str ? str.replace(/\s+/g, '-').toLowerCase() : '')
7
7
  }))
8
8
 
9
9
  jest.mock('../Tooltip/Tooltip', () => ({
10
- Tooltip: ({ content, children }) => <div data-testid='tooltip' data-content={content}>{children}</div>
10
+ Tooltip: ({ content, children }) => (
11
+ <div data-testid='tooltip' data-content={content}>{children}</div>
12
+ )
11
13
  }))
12
14
 
13
15
  jest.mock('../Icon/Icon', () => ({
14
- Icon: ({ id, svgContent }) => <svg data-testid={id || 'custom-icon'} data-svg={svgContent} aria-hidden='true' />
16
+ Icon: ({ id, svgContent }) => (
17
+ <svg data-testid={id || 'custom-icon'} data-svg={svgContent} aria-hidden='true' />
18
+ )
15
19
  }))
16
20
 
17
21
  jest.mock('../../renderer/SlotRenderer', () => ({
18
22
  SlotRenderer: ({ slot }) => <div data-testid='slot' data-slot={slot} />
19
23
  }))
20
24
 
21
- jest.mock('../../store/configContext', () => ({
22
- useConfig: () => ({ id: 'app' })
25
+ jest.mock('../PopupMenu/PopupMenu', () => ({
26
+ PopupMenu: ({ startPos, items }) => {
27
+ let selectedIndex = -1
28
+ if (startPos === 'first' && items?.length > 0) {
29
+ selectedIndex = 0
30
+ }
31
+ if (startPos === 'last' && items?.length > 0) {
32
+ selectedIndex = items.length - 1
33
+ }
34
+ return <div data-testid='popup-menu' data-start-pos={String(startPos)} data-selected-index={String(selectedIndex)}>{items?.map((item, i) => <div key={i} data-testid={`menu-item-${i}`}>{item.label}</div>)}</div>
35
+ }
23
36
  }))
24
37
 
38
+ jest.mock('../../store/configContext', () => ({ useConfig: () => ({ id: 'app' }) }))
39
+
25
40
  const mockButtonRefs = { current: {} }
26
- jest.mock('../../store/appContext', () => ({
27
- useApp: () => ({ buttonRefs: mockButtonRefs })
28
- }))
41
+ jest.mock('../../store/appContext', () => ({ useApp: () => ({ buttonRefs: mockButtonRefs }) }))
29
42
 
30
43
  describe('MapButton', () => {
31
- beforeEach(() => {
32
- mockButtonRefs.current = {}
33
- })
34
-
35
- const renderButton = (props = {}) =>
36
- render(<MapButton buttonId='Test' iconId='icon' label='Label' {...props} />)
44
+ beforeEach(() => { mockButtonRefs.current = {} })
37
45
 
38
- it('renders button with classes, icon, and label', () => {
39
- renderButton({ buttonId: 'My Button', variant: 'primary', showLabel: true })
40
- const button = screen.getByRole('button', { name: 'Label' })
41
- expect(button).toHaveClass('im-c-map-button', 'im-c-map-button--my-button', 'im-c-map-button--primary', 'im-c-map-button--with-label')
42
- expect(screen.getByTestId('icon')).toBeInTheDocument()
43
- expect(screen.getByText('Label')).toBeInTheDocument()
44
- })
45
-
46
- it('renders Icon with svgContent when provided', () => {
47
- renderButton({ iconId: null, iconSvgContent: '<circle />' })
48
- expect(screen.getByTestId('custom-icon')).toHaveAttribute('data-svg', '<circle />')
49
- })
46
+ const renderButton = (props = {}) => render(<MapButton buttonId='Test' iconId='icon' label='Label' {...props} />)
47
+ const getButton = () => screen.getByRole('button')
50
48
 
51
- it('does not render Icon when neither iconId nor iconSvgContent provided', () => {
52
- renderButton({ iconId: null, iconSvgContent: null })
53
- expect(screen.queryByTestId('icon')).not.toBeInTheDocument()
54
- expect(screen.queryByTestId('custom-icon')).not.toBeInTheDocument()
49
+ it.each([
50
+ [{ buttonId: 'My Button', variant: 'primary', showLabel: true }, ['im-c-map-button', 'im-c-map-button--my-button', 'im-c-map-button--primary', 'im-c-map-button--with-label']],
51
+ [{ iconId: null, iconSvgContent: '<circle />' }, []]
52
+ ])('renders button variations and classes', (props, expectedClasses) => {
53
+ renderButton(props)
54
+ const button = getButton()
55
+ expectedClasses.forEach(cls => expect(button).toHaveClass(cls))
56
+ if (props.iconSvgContent) expect(screen.getByTestId('custom-icon')).toHaveAttribute('data-svg', props.iconSvgContent)
55
57
  })
56
58
 
57
59
  it('wraps in Tooltip when showLabel is false', () => {
@@ -59,7 +61,7 @@ describe('MapButton', () => {
59
61
  expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Label')
60
62
  })
61
63
 
62
- it('hides wrapper when isHidden is true', () => {
64
+ it('hides wrapper when isHidden', () => {
63
65
  const { container } = renderButton({ isHidden: true })
64
66
  expect(container.firstChild).toHaveStyle('display: none')
65
67
  })
@@ -68,89 +70,131 @@ describe('MapButton', () => {
68
70
  ['groupStart', 'im-c-button-wrapper--group-start'],
69
71
  ['groupMiddle', 'im-c-button-wrapper--group-middle'],
70
72
  ['groupEnd', 'im-c-button-wrapper--group-end']
71
- ])('applies %s class', (prop, className) => {
73
+ ])('applies wrapper %s class', (prop, className) => {
72
74
  const { container } = renderButton({ [prop]: true })
73
75
  expect(container.firstChild).toHaveClass(className)
74
76
  })
75
77
 
76
- it('applies all aria attributes with panelId', () => {
77
- renderButton({ panelId: 'Settings', idPrefix: 'prefix', isDisabled: true, isExpanded: true, isOpen: false })
78
- const button = screen.getByRole('button')
78
+ it('handles panelId aria attributes', () => {
79
+ renderButton({ panelId: 'Settings', idPrefix: 'prefix', isDisabled: true, isPanelOpen: false })
80
+ const button = getButton()
79
81
  expect(button).toHaveAttribute('aria-disabled', 'true')
80
- expect(button).toHaveAttribute('aria-expanded', 'true')
81
- expect(button).toHaveAttribute('aria-pressed', 'false')
82
+ expect(button).toHaveAttribute('aria-expanded', 'false')
83
+ expect(button).not.toHaveAttribute('aria-pressed')
82
84
  expect(button).toHaveAttribute('aria-controls', 'prefix-panel-settings')
83
85
  expect(screen.getByTestId('slot')).toHaveAttribute('data-slot', 'test-button')
84
86
  })
85
87
 
86
- it('sets aria-pressed true when isOpen is true', () => {
87
- renderButton({ panelId: 'Menu', idPrefix: 'prefix', isOpen: true })
88
- expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
88
+ it.each([
89
+ [{ isPressed: true }, 'true'],
90
+ [{ isPressed: 'true' }, undefined]
91
+ ])('sets aria-pressed correctly', (props, expected) => {
92
+ renderButton(props)
93
+ const button = getButton()
94
+ if (expected) expect(button).toHaveAttribute('aria-pressed', expected)
95
+ else expect(button).not.toHaveAttribute('aria-pressed')
89
96
  })
90
97
 
91
- it('uses isPressed when no panelId', () => {
92
- renderButton({ isPressed: 'true' })
93
- expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
98
+ it('sets aria-expanded when no panelId and isExpanded', () => {
99
+ renderButton({ isExpanded: true })
100
+ expect(getButton()).toHaveAttribute('aria-expanded', 'true')
94
101
  })
95
102
 
96
- it('omits aria-expanded when undefined', () => {
97
- renderButton({ isExpanded: undefined })
98
- expect(screen.getByRole('button')).not.toHaveAttribute('aria-expanded')
103
+ it('handles click events', () => {
104
+ const onClick = jest.fn()
105
+ renderButton({ onClick })
106
+ fireEvent.click(getButton())
107
+ expect(onClick).toHaveBeenCalled()
99
108
  })
100
109
 
101
- it('fires onClick handler', () => {
102
- const handleClick = jest.fn()
103
- renderButton({ onClick: handleClick })
104
- fireEvent.click(screen.getByRole('button'))
105
- expect(handleClick).toHaveBeenCalled()
110
+ it('does not fire onClick or open popup when disabled', () => {
111
+ const onClick = jest.fn()
112
+ renderButton({ isDisabled: true, menuItems: [{ label: 'Item' }], onClick })
113
+ fireEvent.click(getButton())
114
+ expect(onClick).not.toHaveBeenCalled()
115
+ expect(screen.queryByTestId('popup-menu')).not.toBeInTheDocument()
106
116
  })
107
117
 
108
- it('stores button ref when buttonId provided', () => {
118
+ it('stores button refs correctly', () => {
109
119
  renderButton({ buttonId: 'TestButton' })
110
- expect(mockButtonRefs.current.TestButton).toBe(screen.getByRole('button'))
120
+ expect(mockButtonRefs.current.TestButton).toBe(getButton())
111
121
  })
112
122
 
113
- it('does not store ref when buttonRefs.current is falsy', () => {
114
- mockButtonRefs.current = null
115
- renderButton({ buttonId: 'Test' })
116
- // Should not throw
123
+ it('does not store ref if buttonId falsy', () => {
124
+ render(<MapButton buttonId={null} iconId='icon' label='Label' />)
125
+ expect(mockButtonRefs.current).toEqual({})
117
126
  })
118
127
 
119
- it('renders as anchor with href', () => {
120
- renderButton({ href: 'https://example.com' })
121
- const link = screen.getByRole('button')
122
- expect(link.tagName).toBe('A')
123
- expect(link).toHaveAttribute('href', 'https://example.com')
124
- expect(link).toHaveAttribute('target', '_blank')
128
+ it('handles popup aria attributes', () => {
129
+ renderButton({ idPrefix: 'prefix', menuItems: [{ label: 'Item' }] })
130
+ const button = getButton()
131
+ expect(button).toHaveAttribute('aria-controls', 'prefix-popup-test')
132
+ expect(button).toHaveAttribute('aria-haspopup', 'true')
125
133
  })
126
134
 
127
- it.each([[' '], ['Spacebar']])('triggers click when %s key pressed', (key) => {
128
- renderButton({ href: 'https://example.com' })
129
- const link = screen.getByRole('button')
130
- const clickSpy = jest.spyOn(link, 'click')
131
- fireEvent.keyUp(link, { key })
132
- expect(clickSpy).toHaveBeenCalled()
135
+ it('toggles popup aria-expanded and startPos', () => {
136
+ renderButton({ menuItems: [{ label: 'Item' }] })
137
+ const button = getButton()
138
+
139
+ expect(button).toHaveAttribute('aria-expanded', 'false')
140
+
141
+ fireEvent.click(button)
142
+ expect(button).toHaveAttribute('aria-expanded', 'true')
143
+ expect(screen.getByTestId('popup-menu')).toHaveAttribute('data-start-pos', 'null')
144
+
145
+ fireEvent.click(button)
146
+ expect(button).toHaveAttribute('aria-expanded', 'false')
133
147
  })
134
148
 
135
- it('prevents default when Space pressed on anchor', () => {
136
- renderButton({ href: 'https://example.com' })
137
- const event = new KeyboardEvent('keyup', { key: ' ', bubbles: true, cancelable: true })
138
- const preventDefaultSpy = jest.spyOn(event, 'preventDefault')
139
- screen.getByRole('button').dispatchEvent(event)
140
- expect(preventDefaultSpy).toHaveBeenCalled()
149
+ it.each([
150
+ ['ArrowDown', 'first', 0],
151
+ ['ArrowUp', 'last', 1]
152
+ ])('keyboard menu navigation %s', (key, expectedPos, expectedIndex) => {
153
+ renderButton({ menuItems: [{ label: 'One' }, { label: 'Two' }] })
154
+ fireEvent.keyUp(getButton(), { key })
155
+ const menu = screen.getByTestId('popup-menu')
156
+ expect(menu).toHaveAttribute('data-start-pos', expectedPos)
157
+ expect(menu).toHaveAttribute('data-selected-index', String(expectedIndex))
141
158
  })
142
159
 
143
- it('does not trigger click for non-space keys', () => {
160
+ it('does nothing for arrow keys when no menu', () => {
161
+ renderButton()
162
+ fireEvent.keyUp(getButton(), { key: 'ArrowDown' })
163
+ expect(screen.queryByTestId('popup-menu')).not.toBeInTheDocument()
164
+ })
165
+
166
+ it('does nothing for unrelated keys', () => {
167
+ renderButton({ menuItems: [{ label: 'Item' }] })
168
+ fireEvent.keyUp(getButton(), { key: 'Enter' })
169
+ expect(screen.queryByTestId('popup-menu')).not.toBeInTheDocument()
170
+ })
171
+
172
+ it.each([
173
+ ['https://example.com', 'A'],
174
+ [null, 'BUTTON']
175
+ ])('renders as anchor or button', (href, tag) => {
176
+ renderButton({ href })
177
+ const el = getButton()
178
+ expect(el.tagName).toBe(tag)
179
+ if (href) {
180
+ expect(el).toHaveAttribute('href', href)
181
+ expect(el).toHaveAttribute('target', '_blank')
182
+ } else expect(el).toHaveAttribute('type', 'button')
183
+ })
184
+
185
+ it.each([[' '], ['Spacebar']])('triggers click on anchor key %s', (key) => {
144
186
  renderButton({ href: 'https://example.com' })
145
- const clickSpy = jest.spyOn(screen.getByRole('button'), 'click')
146
- fireEvent.keyUp(screen.getByRole('button'), { key: 'Enter' })
147
- expect(clickSpy).not.toHaveBeenCalled()
187
+ const el = getButton()
188
+ const spy = jest.spyOn(el, 'click')
189
+ fireEvent.keyUp(el, { key })
190
+ expect(spy).toHaveBeenCalled()
148
191
  })
149
192
 
150
- it('renders as button without href', () => {
151
- renderButton()
152
- const button = screen.getByRole('button')
153
- expect(button.tagName).toBe('BUTTON')
154
- expect(button).toHaveAttribute('type', 'button')
193
+ it('does not trigger click for other keys on anchor', () => {
194
+ renderButton({ href: 'https://example.com' })
195
+ const el = getButton()
196
+ const spy = jest.spyOn(el, 'click')
197
+ fireEvent.keyUp(el, { key: 'Enter' })
198
+ expect(spy).not.toHaveBeenCalled()
155
199
  })
156
200
  })
@@ -13,7 +13,7 @@ export const Markers = () => {
13
13
  const { markers, markerRef } = useMarkers()
14
14
 
15
15
  if (!mapStyle) {
16
- return
16
+ return undefined
17
17
  }
18
18
 
19
19
  const defaultSvgPaths = markerSvgPaths.find(m => m.shape === markerShape)
@@ -12,11 +12,6 @@
12
12
  }
13
13
 
14
14
  // 2. Elements
15
- .im-c-marker__background {
16
- // stroke: var(--map-overlay-halo-color);
17
- // stroke-width: 1;
18
- }
19
-
20
15
  .im-c-marker__graphic {
21
16
  fill: var(--map-overlay-halo-color);
22
17
  }
@@ -6,9 +6,58 @@ import { useModalPanelBehaviour } from '../../hooks/useModalPanelBehaviour.js'
6
6
  import { useIsScrollable } from '../../hooks/useIsScrollable.js'
7
7
  import { Icon } from '../Icon/Icon'
8
8
 
9
+ const computePanelState = (bpConfig, triggeringElement) => {
10
+ const isAside = bpConfig.slot === 'side' && bpConfig.initiallyOpen && !bpConfig.modal
11
+ const isDialog = !isAside && bpConfig.dismissable
12
+ const isModal = bpConfig.modal === true
13
+ const isDismissable = bpConfig.dismissable !== false
14
+ const shouldFocus = Boolean(isModal || triggeringElement)
15
+ const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
16
+ return { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl }
17
+ }
18
+
19
+ const getPanelRole = (isDialog, isDismissable) => {
20
+ if (isDialog) {
21
+ return 'dialog'
22
+ }
23
+ if (isDismissable) {
24
+ return 'complementary'
25
+ }
26
+ return 'region'
27
+ }
28
+
29
+ const buildPanelClassNames = (slot, showLabel) => [
30
+ 'im-c-panel',
31
+ `im-c-panel--${slot}`,
32
+ !showLabel && 'im-c-panel--no-heading'
33
+ ].filter(Boolean).join(' ')
34
+
35
+ const buildPanelBodyClassNames = (showLabel, isDismissable) => [
36
+ 'im-c-panel__body',
37
+ !showLabel && isDismissable && 'im-c-panel__body--offset'
38
+ ].filter(Boolean).join(' ')
39
+
40
+ const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissable, isModal, width, panelClass }) => ({
41
+ id: elementId,
42
+ 'aria-labelledby': `${elementId}-label`,
43
+ tabIndex: shouldFocus ? -1 : undefined, // nosonar
44
+ role: getPanelRole(isDialog, isDismissable),
45
+ 'aria-modal': isDialog && isModal ? 'true' : undefined,
46
+ style: width ? { width } : undefined,
47
+ className: panelClass
48
+ })
49
+
50
+ const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }) => ({
51
+ ref: bodyRef,
52
+ className: panelBodyClass,
53
+ tabIndex: isBodyScrollable ? 0 : undefined, // nosonar
54
+ role: isBodyScrollable ? 'region' : undefined,
55
+ 'aria-labelledby': isBodyScrollable ? `${elementId}-label` : undefined
56
+ })
57
+
9
58
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
10
59
  // sonarjs/disable-next-line function-name
11
- export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html, children }) => {
60
+ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html, children, isOpen = true, rootRef }) => {
12
61
  const { id } = useConfig()
13
62
  const { dispatch, breakpoint, layoutRefs } = useApp()
14
63
 
@@ -16,54 +65,48 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
16
65
  const bpConfig = panelConfig[breakpoint]
17
66
  const elementId = `${id}-panel-${stringToKebab(panelId)}`
18
67
 
19
- const isAside = bpConfig.slot === 'side' && bpConfig.initiallyOpen && !bpConfig.modal
20
- const isDialog = !isAside && bpConfig.dismissable
21
- const isModal = bpConfig.modal === true
22
- const isDismissable = bpConfig.dismissable !== false
23
- const shouldFocus = Boolean(isModal || props?.triggeringElement)
68
+ const { isAside, isDialog, isModal, isDismissable, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement)
69
+
70
+ // For persistent panels, gate modal behaviour on open state
71
+ const isModalActive = isModal && isOpen
24
72
 
25
- const buttonContainerEl = bpConfig.slot.endsWith('button') ? props?.triggeringElement?.parentNode : undefined
26
73
  const mainRef = layoutRefs.mainRef
27
- const panelRef = useRef(null)
74
+ const internalPanelRef = useRef(null)
28
75
  const bodyRef = useRef(null)
76
+ const prevIsOpenRef = useRef(isOpen)
29
77
  const isBodyScrollable = useIsScrollable(bodyRef)
30
78
 
79
+ // Merge internal ref with optional external rootRef
80
+ const panelRef = rootRef || internalPanelRef
81
+
31
82
  const handleClose = () => {
32
83
  requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.() })
33
84
  dispatch({ type: 'CLOSE_PANEL', payload: panelId })
34
85
  }
35
86
 
36
- useModalPanelBehaviour({ mainRef, panelRef, isModal, isAside, rootEl, buttonContainerEl, handleClose })
87
+ useModalPanelBehaviour({ mainRef, panelRef, isModal: isModalActive, isAside, rootEl, buttonContainerEl, handleClose })
37
88
 
38
89
  useEffect(() => {
39
- if (shouldFocus) {
40
- panelRef.current.focus()
41
- }
42
- }, [])
90
+ // Focus on initial mount (non-persistent) or when isOpen transitions to true (persistent)
91
+ const justOpened = isOpen && !prevIsOpenRef.current
92
+ prevIsOpenRef.current = isOpen
43
93
 
44
- const panelClass = [
45
- 'im-c-panel',
46
- `im-c-panel--${bpConfig.slot}`,
47
- !panelConfig.showLabel && 'im-c-panel--no-heading'
48
- ].filter(Boolean).join(' ')
49
-
50
- const panelBodyClass = [
51
- 'im-c-panel__body',
52
- !panelConfig.showLabel && isDismissable && 'im-c-panel__body--offset'
53
- ].filter(Boolean).join(' ')
94
+ if (shouldFocus && (justOpened || isOpen)) {
95
+ panelRef.current?.focus()
96
+ }
97
+ }, [isOpen])
54
98
 
99
+ const panelClass = buildPanelClassNames(bpConfig.slot, panelConfig.showLabel)
100
+ const panelBodyClass = buildPanelBodyClassNames(panelConfig.showLabel, isDismissable)
55
101
  const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html])
56
102
 
103
+ const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissable, isModal, width: bpConfig.width, panelClass })
104
+ const bodyProps = buildBodyProps({ bodyRef, panelBodyClass, isBodyScrollable, elementId })
105
+
57
106
  return (
58
107
  <div // nosonar
59
108
  ref={panelRef}
60
- id={elementId}
61
- aria-labelledby={`${elementId}-label`}
62
- tabIndex={shouldFocus ? -1 : undefined} // nosonar
63
- role={isDialog ? 'dialog' : (isDismissable && 'complementary') || 'region'}
64
- aria-modal={isDialog && isModal ? 'true' : undefined}
65
- style={bpConfig.width ? { width: bpConfig.width } : undefined}
66
- className={panelClass}
109
+ {...panelProps}
67
110
  >
68
111
  <h2
69
112
  id={`${elementId}-label`}
@@ -83,22 +126,9 @@ export const Panel = ({ panelId, panelConfig, props, WrappedChild, label, html,
83
126
  )}
84
127
 
85
128
  {innerHtmlProp
86
- ? (
87
- <div // nosonar
88
- ref={bodyRef}
89
- className={panelBodyClass}
90
- tabIndex={isBodyScrollable ? 0 : undefined} // nosonar
91
- dangerouslySetInnerHTML={innerHtmlProp}
92
- />
93
- )
129
+ ? <div {...bodyProps} dangerouslySetInnerHTML={innerHtmlProp} /> // nosonar
94
130
  : (
95
- <div // nosonar
96
- ref={bodyRef}
97
- className={panelBodyClass}
98
- tabIndex={isBodyScrollable ? 0 : undefined} // nosonar
99
- role={isBodyScrollable ? 'region' : undefined}
100
- aria-labelledby={isBodyScrollable ? `${elementId}-label` : undefined}
101
- >
131
+ <div {...bodyProps}> {/* nosonar */}
102
132
  {WrappedChild ? <WrappedChild {...props} /> : children}
103
133
  </div>
104
134
  )}
@@ -11,14 +11,12 @@
11
11
  $type: panel
12
12
  );
13
13
 
14
- & {
15
- display: flex;
16
- flex-direction: column;
17
- max-height: 100vh;
18
- min-width: var(--min-panel-width);
19
- pointer-events: auto;
20
- background-color: var(--background-color);
21
- }
14
+ display: flex;
15
+ flex-direction: column;
16
+ max-height: 100vh;
17
+ min-width: var(--min-panel-width);
18
+ pointer-events: auto;
19
+ background-color: var(--background-color);
22
20
  }
23
21
 
24
22
  // 2. Elements
@@ -39,22 +37,20 @@
39
37
  $type: button
40
38
  );
41
39
 
42
- & {
43
- border: 0;
44
- position: absolute;
45
- top: var(--panel-close-button-gap);
46
- right: var(--panel-close-button-gap);
40
+ border: 0;
41
+ position: absolute;
42
+ top: var(--panel-close-button-gap);
43
+ right: var(--panel-close-button-gap);
47
44
 
48
- padding-left: var(--button-padding);
49
- padding-right: var(--button-padding);
45
+ padding-left: var(--button-padding);
46
+ padding-right: var(--button-padding);
50
47
 
51
- min-height: var(--button-size);
52
- min-width: var(--button-size);
48
+ min-height: var(--button-size);
49
+ min-width: var(--button-size);
53
50
 
54
- color: var(--foreground-color);
55
- background-color: transparent;
56
- cursor: pointer;
57
- }
51
+ color: var(--foreground-color);
52
+ background-color: transparent;
53
+ cursor: pointer;
58
54
  }
59
55
 
60
56
  .im-c-panel__body {
@@ -77,9 +73,7 @@
77
73
  transition: background-color var(--duration) ease;
78
74
  }
79
75
 
80
- & {
81
- background-color: var(--button-hover-color);
82
- }
76
+ background-color: var(--button-hover-color);
83
77
  }
84
78
 
85
79
  // 3. Modifiers
@@ -4,6 +4,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
4
4
  import { Panel } from './Panel'
5
5
  import { useConfig } from '../../store/configContext'
6
6
  import { useApp } from '../../store/appContext'
7
+ import { useIsScrollable } from '../../hooks/useIsScrollable.js'
7
8
 
8
9
  jest.mock('../../store/configContext', () => ({ useConfig: jest.fn() }))
9
10
  jest.mock('../../store/appContext', () => ({ useApp: jest.fn() }))
@@ -61,6 +62,23 @@ describe('Panel', () => {
61
62
  renderPanel({ desktop: { slot: 'side', dismissable: true, initiallyOpen: true, width: '300px' } })
62
63
  expect(screen.getByRole('complementary')).toHaveStyle({ width: '300px' })
63
64
  })
65
+
66
+ it('adds scrollable attributes to body when content overflows', () => {
67
+ // 1. Force the mock to true ONLY for this test
68
+ useIsScrollable.mockReturnValue(true)
69
+
70
+ const { container } = renderPanel()
71
+
72
+ // 2. Target by class to avoid role collision with the parent panel
73
+ const body = container.querySelector('.im-c-panel__body')
74
+
75
+ expect(body).toHaveAttribute('tabIndex', '0')
76
+ expect(body).toHaveAttribute('role', 'region')
77
+ expect(body).toHaveAttribute('aria-labelledby', 'app-panel-settings-label')
78
+
79
+ // 3. IMPORTANT: Reset to false so other tests don't see two regions
80
+ useIsScrollable.mockReturnValue(false)
81
+ })
64
82
  })
65
83
 
66
84
  describe('role and aria attributes', () => {