@defra/interactive-map 0.0.22-alpha → 0.0.24-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 (242) hide show
  1. package/assets/images/light-raster-thumb.jpg +0 -0
  2. package/assets/images/outdoor-raster-thumb.jpg +0 -0
  3. package/assets/images/road-raster-thumb.jpg +0 -0
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/esm/im-shell.js +1 -1
  7. package/dist/umd/im-core.js +1 -1
  8. package/dist/umd/index.js +1 -1
  9. package/docs/api/button-definition.md +39 -0
  10. package/docs/api/context.md +4 -4
  11. package/docs/api/map-style-config.md +11 -2
  12. package/docs/api/marker-config.md +38 -7
  13. package/docs/api/panel-definition.md +7 -3
  14. package/docs/api/symbol-config.md +10 -26
  15. package/docs/api/symbol-registry.md +28 -13
  16. package/docs/api.md +24 -2
  17. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  18. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  19. package/docs/examples/index.mdx +12 -0
  20. package/docs/examples/toggle-marker-label.mdx +89 -0
  21. package/docs/plugins/interact.md +4 -11
  22. package/docs/plugins/search.md +70 -0
  23. package/docusaurus.config.cjs +2 -1
  24. package/package.json +23 -9
  25. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  26. package/plugins/beta/datasets/dist/css/index.css +2 -2
  27. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  28. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  29. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  31. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  32. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  33. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  34. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  35. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  36. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  37. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  38. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  39. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  40. package/plugins/beta/draw-ml/dist/css/index.css +1 -21
  41. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  42. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  43. package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
  44. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
  45. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.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/use-location/dist/esm/im-use-location-plugin.js +1 -1
  49. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  50. package/plugins/beta/use-location/src/manifest.js +1 -0
  51. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  53. package/plugins/interact/dist/umd/index.js +1 -1
  54. package/plugins/interact/src/InteractInit.jsx +23 -55
  55. package/plugins/interact/src/InteractInit.test.js +58 -80
  56. package/plugins/interact/src/defaults.js +1 -3
  57. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  58. package/plugins/interact/src/hooks/useAttachEvents.test.js +68 -0
  59. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  60. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  61. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  62. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  65. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  66. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  67. package/plugins/interact/src/index.js +0 -2
  68. package/plugins/interact/src/index.test.js +0 -2
  69. package/plugins/interact/src/manifest.js +37 -6
  70. package/plugins/interact/src/reducer.js +8 -4
  71. package/plugins/interact/src/reducer.test.js +18 -3
  72. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  73. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  74. package/plugins/interact/src/utils/spatial.js +0 -15
  75. package/plugins/interact/src/utils/spatial.test.js +1 -13
  76. package/plugins/search/dist/css/index.css +1 -1
  77. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  78. package/plugins/search/dist/esm/index.js +1 -1
  79. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  80. package/plugins/search/dist/umd/index.js +1 -1
  81. package/plugins/search/src/Search.jsx +2 -1
  82. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  83. package/plugins/search/src/components/Form/Form.jsx +11 -7
  84. package/plugins/search/src/components/Form/Form.test.jsx +23 -1
  85. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  86. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  87. package/plugins/search/src/datasets.js +2 -2
  88. package/plugins/search/src/datasets.test.js +1 -1
  89. package/plugins/search/src/defaults.js +7 -1
  90. package/plugins/search/src/events/fetchSuggestions.js +13 -10
  91. package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
  92. package/plugins/search/src/events/formHandlers.js +2 -4
  93. package/plugins/search/src/events/formHandlers.test.js +3 -3
  94. package/plugins/search/src/events/inputHandlers.js +1 -1
  95. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  96. package/plugins/search/src/events/suggestionHandlers.js +2 -1
  97. package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
  98. package/plugins/search/src/index.js +3 -7
  99. package/plugins/search/src/reducer.js +10 -5
  100. package/plugins/search/src/reducer.test.js +2 -1
  101. package/plugins/search/src/search.scss +1 -0
  102. package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
  103. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  104. package/providers/beta/esri/src/esriProvider.js +4 -0
  105. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
  106. package/providers/beta/openlayers/dist/esm/index.js +1 -0
  107. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
  108. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
  109. package/providers/beta/openlayers/dist/umd/index.js +2 -0
  110. package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
  111. package/providers/beta/openlayers/src/appEvents.js +50 -0
  112. package/providers/beta/openlayers/src/appEvents.test.js +140 -0
  113. package/providers/beta/openlayers/src/defaults.js +36 -0
  114. package/providers/beta/openlayers/src/index.js +21 -0
  115. package/providers/beta/openlayers/src/mapEvents.js +132 -0
  116. package/providers/beta/openlayers/src/mapEvents.test.js +197 -0
  117. package/providers/beta/openlayers/src/openlayersProvider.js +234 -0
  118. package/providers/beta/openlayers/src/openlayersProvider.test.js +331 -0
  119. package/providers/beta/openlayers/src/utils/spatial.js +142 -0
  120. package/providers/beta/openlayers/src/utils/spatial.test.js +117 -0
  121. package/providers/beta/openlayers/src/utils/zoom.js +26 -0
  122. package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
  123. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  124. package/providers/maplibre/dist/esm/index.js +1 -1
  125. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  126. package/providers/maplibre/dist/umd/index.js +1 -1
  127. package/providers/maplibre/src/index.js +1 -1
  128. package/providers/maplibre/src/index.test.js +9 -14
  129. package/providers/maplibre/src/mapEvents.js +11 -5
  130. package/providers/maplibre/src/mapEvents.test.js +14 -3
  131. package/providers/maplibre/src/maplibreProvider.js +18 -11
  132. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  133. package/providers/maplibre/src/utils/highlightFeatures.js +112 -57
  134. package/providers/maplibre/src/utils/highlightFeatures.test.js +179 -51
  135. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  136. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  137. package/providers/maplibre/src/utils/patternImages.js +22 -19
  138. package/providers/maplibre/src/utils/patternImages.test.js +146 -167
  139. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  140. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  141. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  142. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  143. package/rollup.esm.mjs +14 -3
  144. package/src/App/components/Actions/Actions.module.scss +1 -0
  145. package/src/App/components/Actions/Actions.test.jsx +17 -4
  146. package/src/App/components/Hints/Hints.jsx +52 -0
  147. package/src/App/components/Hints/Hints.module.scss +37 -0
  148. package/src/App/components/Hints/Hints.test.jsx +104 -0
  149. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  150. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  151. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  152. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  153. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  154. package/src/App/components/Markers/Markers.jsx +44 -75
  155. package/src/App/components/Markers/Markers.module.scss +58 -0
  156. package/src/App/components/Markers/Markers.test.jsx +90 -113
  157. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  158. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  159. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  160. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  161. package/src/App/components/Panel/Panel.jsx +1 -1
  162. package/src/App/components/Panel/Panel.module.scss +1 -0
  163. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  164. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  165. package/src/App/components/Tabs/Tabs.jsx +68 -0
  166. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  167. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  168. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  169. package/src/App/components/Viewport/Features.jsx +35 -0
  170. package/src/App/components/Viewport/Features.module.scss +31 -0
  171. package/src/App/components/Viewport/Features.test.jsx +124 -0
  172. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  173. package/src/App/components/Viewport/Viewport.jsx +35 -29
  174. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  175. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  176. package/src/App/controls/keyboardActions.js +9 -3
  177. package/src/App/controls/keyboardActions.test.js +122 -87
  178. package/src/App/controls/keyboardShortcuts.js +1 -14
  179. package/src/App/hooks/useFeatureFocus.js +199 -0
  180. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  181. package/src/App/hooks/useFeatureItems.js +30 -0
  182. package/src/App/hooks/useFeatureItems.test.js +98 -0
  183. package/src/App/hooks/useFocusVisible.js +4 -7
  184. package/src/App/hooks/useFocusVisible.test.js +6 -6
  185. package/src/App/hooks/useKeyboardHint.js +9 -34
  186. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  187. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  188. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  189. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  190. package/src/App/hooks/useLayoutMeasurements.test.js +30 -1
  191. package/src/App/hooks/useMarkerCursor.js +33 -0
  192. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  193. package/src/App/hooks/useMarkersAPI.js +32 -9
  194. package/src/App/hooks/useMarkersAPI.test.js +163 -94
  195. package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
  196. package/src/App/layout/Layout.jsx +2 -0
  197. package/src/App/layout/Layout.test.jsx +3 -0
  198. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  199. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  200. package/src/App/renderer/mapButtons.js +5 -1
  201. package/src/App/renderer/mapButtons.test.js +37 -1
  202. package/src/App/store/AppProvider.test.jsx +5 -0
  203. package/src/App/store/MapProvider.test.jsx +8 -1
  204. package/src/App/store/ServiceProvider.jsx +4 -1
  205. package/src/App/store/ServiceProvider.test.jsx +11 -0
  206. package/src/App/store/appActionsMap.js +5 -1
  207. package/src/App/store/appActionsMap.test.js +13 -0
  208. package/src/App/store/appReducer.js +1 -0
  209. package/src/App/store/mapActionsMap.js +2 -1
  210. package/src/InteractiveMap/InteractiveMap.js +12 -0
  211. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  212. package/src/config/appConfig.js +3 -1
  213. package/src/config/appConfig.test.js +4 -0
  214. package/src/config/defaults.js +1 -1
  215. package/src/config/events.js +8 -0
  216. package/src/config/mapTheme.js +8 -4
  217. package/src/config/symbolConfig.js +23 -16
  218. package/src/scss/main.scss +3 -0
  219. package/src/scss/tools/_border-focus.scss +0 -1
  220. package/src/services/announcer.test.js +5 -7
  221. package/src/services/eventBus.js +15 -0
  222. package/src/services/eventBus.test.js +11 -0
  223. package/src/services/hints.js +47 -0
  224. package/src/services/hints.test.js +110 -0
  225. package/src/services/patternRegistry.js +69 -1
  226. package/src/services/patternRegistry.test.js +112 -1
  227. package/src/services/reverseGeocode.js +4 -0
  228. package/src/services/reverseGeocode.test.js +11 -1
  229. package/src/services/symbolRegistry.js +152 -17
  230. package/src/services/symbolRegistry.test.js +141 -31
  231. package/src/types.js +71 -14
  232. package/src/utils/constrainKeyboardFocus.js +5 -5
  233. package/src/utils/detectInterfaceType.js +27 -5
  234. package/src/utils/detectInterfaceType.test.js +29 -3
  235. package/src/utils/patternUtils.js +4 -67
  236. package/src/utils/patternUtils.test.js +1 -123
  237. package/src/utils/symbolUtils.js +11 -13
  238. package/src/utils/symbolUtils.test.js +18 -26
  239. package/webpack.dev.mjs +6 -1
  240. package/webpack.umd.mjs +1 -0
  241. package/plugins/interact/dist/css/index.css +0 -1
  242. package/plugins/interact/src/interact.scss +0 -24
@@ -0,0 +1,121 @@
1
+ import React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { Tabs } from './Tabs'
4
+
5
+ const TAB_A = { name: 'Alpha', content: <p>Alpha content</p> }
6
+ const TAB_B = { name: 'Beta', content: <p>Beta content</p> }
7
+ const TAB_C = { name: 'Gamma', content: <p>Gamma content</p> }
8
+
9
+ // ─── rendering ───────────────────────────────────────────────────────────────
10
+
11
+ describe('Tabs — rendering', () => {
12
+ it('renders a tab button for each entry', () => {
13
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
14
+ expect(screen.getByRole('tab', { name: 'Alpha' })).toBeInTheDocument()
15
+ expect(screen.getByRole('tab', { name: 'Beta' })).toBeInTheDocument()
16
+ })
17
+
18
+ it('shows content for the active tab only', () => {
19
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
20
+ expect(screen.getByText('Alpha content')).toBeInTheDocument()
21
+ expect(screen.queryByText('Beta content')).not.toBeInTheDocument()
22
+ })
23
+
24
+ it('uses defaultTab to set the initial active tab', () => {
25
+ render(<Tabs tabs={[TAB_A, TAB_B]} defaultTab='Beta' />)
26
+ expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true')
27
+ expect(screen.getByText('Beta content')).toBeInTheDocument()
28
+ expect(screen.queryByText('Alpha content')).not.toBeInTheDocument()
29
+ })
30
+
31
+ it('falls back to first tab when defaultTab is not provided', () => {
32
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
33
+ expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true')
34
+ })
35
+ })
36
+
37
+ // ─── click behaviour ──────────────────────────────────────────────────────────
38
+
39
+ describe('Tabs — click behaviour', () => {
40
+ it('switches to the clicked tab', () => {
41
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
42
+ fireEvent.click(screen.getByRole('tab', { name: 'Beta' }))
43
+ expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true')
44
+ expect(screen.getByText('Beta content')).toBeInTheDocument()
45
+ expect(screen.queryByText('Alpha content')).not.toBeInTheDocument()
46
+ })
47
+ })
48
+
49
+ // ─── WCAG attributes ─────────────────────────────────────────────────────────
50
+
51
+ describe('Tabs — WCAG attributes', () => {
52
+ it('active tab has tabIndex 0, inactive tabs have tabIndex -1', () => {
53
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
54
+ expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('tabindex', '0')
55
+ expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('tabindex', '-1')
56
+ })
57
+
58
+ it('each tab has aria-controls pointing to an element in the DOM', () => {
59
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
60
+ const tab = screen.getByRole('tab', { name: 'Alpha' })
61
+ expect(document.getElementById(tab.getAttribute('aria-controls'))).toBeInTheDocument()
62
+ })
63
+
64
+ it('panel has aria-labelledby pointing to the active tab', () => {
65
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
66
+ const panel = screen.getByRole('tabpanel')
67
+ const tabId = panel.getAttribute('aria-labelledby')
68
+ expect(document.getElementById(tabId)).toHaveAttribute('aria-selected', 'true')
69
+ })
70
+
71
+ it('panel has tabIndex -1 per ARIA tabs pattern', () => {
72
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
73
+ expect(screen.getByRole('tabpanel')).toHaveAttribute('tabindex', '-1')
74
+ })
75
+ })
76
+
77
+ // ─── WCAG keyboard navigation ─────────────────────────────────────────────────
78
+
79
+ describe('Tabs — WCAG keyboard navigation', () => {
80
+ it('ArrowRight moves to next tab', () => {
81
+ render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} />)
82
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Alpha' }), { key: 'ArrowRight' })
83
+ expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true')
84
+ })
85
+
86
+ it('ArrowLeft moves to previous tab', () => {
87
+ render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} defaultTab='Beta' />)
88
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Beta' }), { key: 'ArrowLeft' })
89
+ expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true')
90
+ })
91
+
92
+ it('ArrowRight wraps from last tab to first', () => {
93
+ render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} defaultTab='Gamma' />)
94
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Gamma' }), { key: 'ArrowRight' })
95
+ expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true')
96
+ })
97
+
98
+ it('ArrowLeft wraps from first tab to last', () => {
99
+ render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} />)
100
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Alpha' }), { key: 'ArrowLeft' })
101
+ expect(screen.getByRole('tab', { name: 'Gamma' })).toHaveAttribute('aria-selected', 'true')
102
+ })
103
+
104
+ it('Home moves to first tab', () => {
105
+ render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} defaultTab='Gamma' />)
106
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Gamma' }), { key: 'Home' })
107
+ expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true')
108
+ })
109
+
110
+ it('End moves to last tab', () => {
111
+ render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} />)
112
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Alpha' }), { key: 'End' })
113
+ expect(screen.getByRole('tab', { name: 'Gamma' })).toHaveAttribute('aria-selected', 'true')
114
+ })
115
+
116
+ it('unhandled keys do not change the active tab', () => {
117
+ render(<Tabs tabs={[TAB_A, TAB_B]} />)
118
+ fireEvent.keyDown(screen.getByRole('tab', { name: 'Alpha' }), { key: 'Enter' })
119
+ expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true')
120
+ })
121
+ })
@@ -35,7 +35,7 @@
35
35
 
36
36
  padding: var(--tooltip-padding);
37
37
  font-size: var(--tooltip-font-size);
38
- line-height: 1;
38
+ line-height: 1.2;
39
39
 
40
40
  @media (prefers-reduced-motion: no-preference) {
41
41
  transition: opacity var(--duration) ease, visibility var(--duration) ease;
@@ -0,0 +1,35 @@
1
+ import React, { forwardRef } from 'react'
2
+ import { useConfig } from '../../store/configContext.js'
3
+
4
+ export const Features = forwardRef(({ activeFeatureId, selectedIds = [], multiselectable = false, items = [], onFocus, onBlur }, ref) => {
5
+ const { id } = useConfig()
6
+ const hasItems = items.length > 0
7
+ return (
8
+ <ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
9
+ id={`${id}-features`}
10
+ ref={ref}
11
+ role='listbox' // NOSONAR
12
+ tabIndex={hasItems ? '0' : '-1'}
13
+ aria-hidden={hasItems ? undefined : true}
14
+ aria-label='Map features'
15
+ aria-describedby={`${id}-keyboard-desc`}
16
+ aria-multiselectable={multiselectable || undefined}
17
+ aria-activedescendant={activeFeatureId ? `${id}-feature-${activeFeatureId}` : undefined}
18
+ className='im-c-features'
19
+ onFocus={onFocus}
20
+ onBlur={onBlur}
21
+ >
22
+ {items.map(item => (
23
+ <li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern
24
+ key={item.id} id={`${id}-feature-${item.id}`} role='option' // NOSONAR
25
+ data-id={item.id}
26
+ aria-selected={selectedIds.includes(item.id)}
27
+ >
28
+ {item.label}
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ )
33
+ })
34
+
35
+ Features.displayName = 'Features'
@@ -0,0 +1,31 @@
1
+ @use '../../../scss/tools/index' as tools;
2
+
3
+ // ===================================================
4
+ // Component: Features
5
+ // ===================================================
6
+
7
+ .im-c-features {
8
+ position: absolute;
9
+ inset: 0;
10
+ pointer-events: none;
11
+ list-style: none;
12
+ margin: 0;
13
+ padding: 0;
14
+
15
+ @include tools.border-focus-base(
16
+ $is-inset: true
17
+ );
18
+
19
+ &[data-focus-visible="true"][aria-activedescendant]::after {
20
+ opacity: 0;
21
+ }
22
+
23
+ [role="option"] {
24
+ position: absolute;
25
+ width: 1px;
26
+ height: 1px;
27
+ overflow: hidden;
28
+ clip-path: inset(50%);
29
+ white-space: nowrap;
30
+ }
31
+ }
@@ -0,0 +1,124 @@
1
+ import React from 'react'
2
+ import { render, fireEvent } from '@testing-library/react'
3
+ import { Features } from './Features.jsx'
4
+ import { useConfig } from '../../store/configContext.js'
5
+
6
+ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
7
+
8
+ const APP_ID = 'test-app'
9
+ const LISTBOX = '[role="listbox"]' // NOSONAR
10
+ const OPTION = '[role="option"]' // NOSONAR
11
+ const ARIA_SELECTED = 'aria-selected'
12
+ const ITEMS = [
13
+ { id: 'f1', label: 'Feature One' },
14
+ { id: 'f2', label: 'Feature Two' }
15
+ ]
16
+
17
+ beforeEach(() => {
18
+ useConfig.mockReturnValue({ id: APP_ID })
19
+ })
20
+
21
+ // ─── Features — rendering ─────────────────────────────────────────────────────
22
+
23
+ describe('Features — rendering', () => {
24
+ it('renders a listbox with the correct id', () => {
25
+ const { container } = render(<Features />)
26
+ expect(container.querySelector(`#${APP_ID}-features`)).toBeTruthy()
27
+ expect(container.querySelector(LISTBOX)).toBeTruthy() // NOSONAR
28
+ })
29
+
30
+ it('renders no options when items is empty', () => {
31
+ const { container } = render(<Features />)
32
+ expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR
33
+ })
34
+
35
+ it('renders one option per item with correct id, data-id and label', () => {
36
+ const { container } = render(<Features items={ITEMS} />)
37
+ const options = container.querySelectorAll(OPTION)
38
+ expect(options).toHaveLength(2)
39
+ expect(options[0].getAttribute('id')).toBe(`${APP_ID}-feature-f1`)
40
+ expect(options[0].dataset.id).toBe('f1')
41
+ expect(options[0].textContent).toBe('Feature One')
42
+ expect(options[1].getAttribute('id')).toBe(`${APP_ID}-feature-f2`)
43
+ expect(options[1].dataset.id).toBe('f2')
44
+ expect(options[1].textContent).toBe('Feature Two')
45
+ })
46
+
47
+ it('sets aria-selected on items present in selectedIds', () => {
48
+ const { container } = render(<Features items={ITEMS} selectedIds={['f1']} />)
49
+ const options = container.querySelectorAll(OPTION)
50
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
51
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
52
+ })
53
+
54
+ it('sets aria-selected on multiple items when selectedIds has multiple entries', () => {
55
+ const { container } = render(<Features items={ITEMS} selectedIds={['f1', 'f2']} />)
56
+ const options = container.querySelectorAll(OPTION)
57
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
58
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true')
59
+ })
60
+
61
+ it('does not set aria-selected from activeFeatureId alone', () => {
62
+ const { container } = render(<Features items={ITEMS} activeFeatureId='f1' />)
63
+ const options = container.querySelectorAll(OPTION)
64
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
65
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
66
+ })
67
+
68
+ it('sets aria-activedescendant to the option element id when activeFeatureId is provided', () => {
69
+ const { container } = render(<Features items={ITEMS} activeFeatureId='f2' />)
70
+ expect(container.querySelector(LISTBOX).getAttribute('aria-activedescendant')).toBe('test-app-feature-f2') // NOSONAR
71
+ })
72
+
73
+ it('omits aria-activedescendant when activeFeatureId is absent', () => {
74
+ const { container } = render(<Features items={ITEMS} />)
75
+ expect(container.querySelector(LISTBOX).getAttribute('aria-activedescendant')).toBeNull() // NOSONAR
76
+ })
77
+
78
+ it('sets aria-multiselectable when multiselectable is true', () => {
79
+ const { container } = render(<Features items={ITEMS} multiselectable />)
80
+ expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBe('true') // NOSONAR
81
+ })
82
+
83
+ it('omits aria-multiselectable when multiselectable is false', () => {
84
+ const { container } = render(<Features items={ITEMS} />)
85
+ expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBeNull() // NOSONAR
86
+ })
87
+
88
+ it('is tabIndex 0 and not aria-hidden when items are present', () => {
89
+ const { container } = render(<Features items={ITEMS} />)
90
+ const ul = container.querySelector(LISTBOX) // NOSONAR
91
+ expect(ul.getAttribute('tabIndex')).toBe('0')
92
+ expect(ul.getAttribute('aria-hidden')).toBeNull()
93
+ })
94
+
95
+ it('is tabIndex -1 and aria-hidden when items is empty', () => {
96
+ const { container } = render(<Features />)
97
+ const ul = container.querySelector(LISTBOX) // NOSONAR
98
+ expect(ul.getAttribute('tabIndex')).toBe('-1')
99
+ expect(ul.getAttribute('aria-hidden')).toBe('true')
100
+ })
101
+
102
+ it('sets aria-describedby to the shared hints container id', () => {
103
+ const { container } = render(<Features />)
104
+ expect(container.querySelector(LISTBOX).getAttribute('aria-describedby')).toBe(`${APP_ID}-keyboard-desc`) // NOSONAR
105
+ })
106
+ })
107
+
108
+ // ─── Features — interactions ──────────────────────────────────────────────────
109
+
110
+ describe('Features — interactions', () => {
111
+ it('calls onFocus when the listbox receives focus', () => {
112
+ const onFocus = jest.fn()
113
+ const { container } = render(<Features onFocus={onFocus} />)
114
+ fireEvent.focus(container.querySelector(LISTBOX)) // NOSONAR
115
+ expect(onFocus).toHaveBeenCalled()
116
+ })
117
+
118
+ it('calls onBlur when the listbox loses focus', () => {
119
+ const onBlur = jest.fn()
120
+ const { container } = render(<Features onBlur={onBlur} />)
121
+ fireEvent.blur(container.querySelector(LISTBOX)) // NOSONAR
122
+ expect(onBlur).toHaveBeenCalled()
123
+ })
124
+ })
@@ -12,10 +12,6 @@ export const MapStatus = () => {
12
12
  ref={mapStatusRef}
13
13
  role='status'
14
14
  className='im-c-viewport__status'
15
- aria-live='polite'
16
- aria-atomic='true'
17
- // aria-relevant="additions"
18
- aria-label='Map updates'
19
15
  />
20
16
  )
21
17
  }
@@ -1,47 +1,58 @@
1
- import React, { useRef, useEffect, useState } from 'react'
1
+ import React, { useRef, useEffect } from 'react'
2
+ import { useFeatureFocus } from '../../hooks/useFeatureFocus.js'
3
+ import { useFeatureItems } from '../../hooks/useFeatureItems.js'
2
4
  import { EVENTS as events } from '../../../config/events.js'
3
- import { createPortal } from 'react-dom'
4
5
  import { useConfig } from '../../store/configContext.js'
5
-
6
6
  import { useApp } from '../../store/appContext.js'
7
7
  import { useMap } from '../../store/mapContext.js'
8
+ import { useService } from '../../store/serviceContext.js'
8
9
  import { MapController } from './MapController.jsx'
9
10
  import { useKeyboardHint } from '../../hooks/useKeyboardHint.js'
10
11
  import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts.js'
11
12
  import { useMapEvents } from '../../hooks/useMapEvents.js'
12
13
  import { MapStatus } from './MapStatus.jsx'
13
14
  import { CrossHair } from '../CrossHair/CrossHair'
15
+ import { Features } from './Features'
14
16
  import { Markers } from '../Markers/Markers'
15
17
 
16
18
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
17
19
  // sonarjs/disable-next-line function-name
18
20
  export const Viewport = () => {
19
21
  const { id, mapProvider, mapLabel, keyboardHintText } = useConfig()
20
- const { interfaceType, mode, previousMode, layoutRefs, safeZoneInset } = useApp()
21
- const { mainRef } = layoutRefs
22
+ const { mode, previousMode, layoutRefs, safeZoneInset, dispatch } = useApp()
22
23
  const { mapSize } = useMap()
24
+ const { eventBus, hints } = useService()
23
25
 
24
26
  const mapContainerRef = useRef(null)
25
- const keyboardHintRef = useRef(null)
27
+ const featuresRef = useRef(null)
28
+
29
+ const { items: featureItems, multiselectable } = useFeatureItems(eventBus)
30
+ const { activeFeatureId, selectedIds, onFocus: handleFeaturesFocus, onBlur: handleFeaturesBlur } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus, hints })
31
+
32
+ useEffect(() => {
33
+ const handler = () => dispatch({ type: 'SET_LISTBOX_ACTIVE' })
34
+ eventBus.on('interact:listboxcapable', handler)
35
+ return () => eventBus.off('interact:listboxcapable', handler)
36
+ }, [eventBus])
26
37
 
27
- // Local state for keyboard hint visibility
28
- const [keyboardHintVisible, setKeyboardHintVisible] = useState(false)
38
+ const onFeaturesFocus = () => { handleFeaturesFocus(); hints.show(keyboardHintText, { duration: 0 }) }
39
+ const onFeaturesBlur = () => { handleFeaturesBlur(); hints.dismiss() }
29
40
 
30
- // Attach map keyboard controls
31
41
  useKeyboardShortcuts(layoutRefs.viewportRef)
32
42
 
33
- // Attach map events
34
43
  useMapEvents({
35
44
  [events.MAP_CLICK]: () => mapProvider?.clearHighlightedLabel?.()
36
45
  })
37
46
 
38
- // Manage keyboard hint visibility using local state
39
- const { showHint, handleFocus, handleBlur } = useKeyboardHint({
40
- interfaceType,
47
+ const { handleFocus, handleBlur } = useKeyboardHint({
41
48
  containerRef: layoutRefs.viewportRef,
42
- keyboardHintRef,
43
- keyboardHintVisible,
44
- onViewportFocusChange: setKeyboardHintVisible // update local state only
49
+ onViewportFocusChange: (visible) => {
50
+ if (visible) {
51
+ hints.show(keyboardHintText, { duration: 0 })
52
+ } else {
53
+ hints.dismiss()
54
+ }
55
+ }
45
56
  })
46
57
 
47
58
  // Set focus on viewport on mode change
@@ -63,24 +74,19 @@ export const Viewport = () => {
63
74
  onFocus={handleFocus}
64
75
  onBlur={handleBlur}
65
76
  ref={layoutRefs.viewportRef}
77
+ aria-describedby={`${id}-keyboard-desc`}
78
+ aria-controls={`${id}-features`}
66
79
  >
67
- {showHint && mainRef?.current && createPortal(
68
- <div
69
- className='im-c-viewport__keyboard-hint'
70
- aria-hidden='true'
71
- ref={keyboardHintRef}
72
- dangerouslySetInnerHTML={{ __html: keyboardHintText }}
73
- />,
74
- mainRef.current
75
- )}
76
- <div className='im-c-viewport__map-container' ref={mapContainerRef} />
77
- <div className='im-c-viewport__features' />
80
+ <div className='im-c-viewport__map-container' ref={mapContainerRef} aria-hidden='true' />
78
81
  <MapStatus />
79
- <div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef}>
82
+ <div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef} aria-hidden='true'>
80
83
  <CrossHair />
81
84
  </div>
82
- <Markers />
85
+ <div className='im-c-viewport__markers' aria-hidden='true'>
86
+ <Markers />
87
+ </div>
83
88
  </div>
89
+ <Features ref={featuresRef} activeFeatureId={activeFeatureId} selectedIds={selectedIds} multiselectable={multiselectable} items={featureItems} onFocus={onFeaturesFocus} onBlur={onFeaturesBlur} />
84
90
  </>
85
91
  )
86
92
  }
@@ -29,26 +29,6 @@
29
29
 
30
30
  // 2. Elements
31
31
 
32
- .im-c-viewport__keyboard-hint {
33
- position: absolute;
34
- bottom: var(--keyboard-hint-bottom, var(--primary-gap));
35
- left: 50%;
36
- transform: translateX(-50%);
37
- z-index: 1001;
38
- text-wrap: nowrap;
39
-
40
- color: var(--tooltip-foreground-color);
41
- background-color: var(--tooltip-background-color);
42
- border-radius: var(--tooltip-border-radius);
43
- padding: var(--tooltip-padding);
44
- font-size: var(--tooltip-font-size);
45
- line-height: 1;
46
- }
47
-
48
- .im-c-viewport__keyboard-hint kbd {
49
- border: var(--kbd-tooltip-border);
50
- }
51
-
52
32
  .im-c-viewport__map-container > *:first-child {
53
33
  overflow: visible;
54
34
  transform-origin: top left;
@@ -56,7 +36,7 @@
56
36
 
57
37
 
58
38
  // ---------------------------------------------------
59
- // Focus area: The bit of the map thats always visible
39
+ // Safe zone: The bit of the map thats always visible
60
40
  // ---------------------------------------------------
61
41
 
62
42
  .im-c-viewport__safezone {
@@ -65,6 +45,12 @@
65
45
  z-index: 1;
66
46
  }
67
47
 
48
+ .im-c-viewport__markers {
49
+ position: absolute;
50
+ inset: 0;
51
+ pointer-events: none;
52
+ }
53
+
68
54
 
69
55
  // 3. Modifiers
70
56