@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
@@ -1,6 +1,7 @@
1
- import React from 'react'
2
- import { render, fireEvent } from '@testing-library/react'
1
+ import React, { useRef } from 'react'
2
+ import { render, fireEvent, act } from '@testing-library/react'
3
3
  import { Features } from './Features.jsx'
4
+ import { useFeatureFocus } from '../../hooks/useFeatureFocus.js'
4
5
  import { useConfig } from '../../store/configContext.js'
5
6
 
6
7
  jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
@@ -44,6 +45,21 @@ describe('Features — rendering', () => {
44
45
  expect(options[1].textContent).toBe('Feature Two')
45
46
  })
46
47
 
48
+ it('positions an option at its x/y when provided, for coordinate-based AT overlays (e.g. Voice Control)', () => {
49
+ const itemsWithPosition = [{ id: 'f1', label: 'Feature One', x: 120, y: 340 }]
50
+ const { container } = render(<Features items={itemsWithPosition} />)
51
+ const option = container.querySelector(OPTION)
52
+ expect(option.style.left).toBe('120px')
53
+ expect(option.style.top).toBe('340px')
54
+ })
55
+
56
+ it('does not set left/top when an item has no x/y', () => {
57
+ const { container } = render(<Features items={ITEMS} />)
58
+ const option = container.querySelector(OPTION)
59
+ expect(option.style.left).toBe('')
60
+ expect(option.style.top).toBe('')
61
+ })
62
+
47
63
  it('sets aria-selected on items present in selectedIds', () => {
48
64
  const { container } = render(<Features items={ITEMS} selectedIds={['f1']} />)
49
65
  const options = container.querySelectorAll(OPTION)
@@ -65,14 +81,18 @@ describe('Features — rendering', () => {
65
81
  expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
66
82
  })
67
83
 
68
- it('sets aria-activedescendant to the option element id when activeFeatureId is provided', () => {
84
+ it('gives the active item tabIndex 0 and every other item tabIndex -1 (roving tabindex)', () => {
69
85
  const { container } = render(<Features items={ITEMS} activeFeatureId='f2' />)
70
- expect(container.querySelector(LISTBOX).getAttribute('aria-activedescendant')).toBe('test-app-feature-f2') // NOSONAR
86
+ const options = container.querySelectorAll(OPTION)
87
+ expect(options[0].getAttribute('tabIndex')).toBe('-1')
88
+ expect(options[1].getAttribute('tabIndex')).toBe('0')
71
89
  })
72
90
 
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
91
+ it('falls back to tabbableId for roving tabindex when activeFeatureId is absent', () => {
92
+ const { container } = render(<Features items={ITEMS} tabbableId='f1' />)
93
+ const options = container.querySelectorAll(OPTION)
94
+ expect(options[0].getAttribute('tabIndex')).toBe('0')
95
+ expect(options[1].getAttribute('tabIndex')).toBe('-1')
76
96
  })
77
97
 
78
98
  it('sets aria-multiselectable when multiselectable is true', () => {
@@ -85,18 +105,17 @@ describe('Features — rendering', () => {
85
105
  expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBeNull() // NOSONAR
86
106
  })
87
107
 
88
- it('is tabIndex 0 and not aria-hidden when items are present', () => {
108
+ it('is not aria-hidden when items are present', () => {
89
109
  const { container } = render(<Features items={ITEMS} />)
90
110
  const ul = container.querySelector(LISTBOX) // NOSONAR
91
- expect(ul.getAttribute('tabIndex')).toBe('0')
92
111
  expect(ul.getAttribute('aria-hidden')).toBeNull()
93
112
  })
94
113
 
95
- it('is tabIndex -1 and aria-hidden when items is empty', () => {
114
+ it('is aria-hidden and has no options when items is empty', () => {
96
115
  const { container } = render(<Features />)
97
116
  const ul = container.querySelector(LISTBOX) // NOSONAR
98
- expect(ul.getAttribute('tabIndex')).toBe('-1')
99
117
  expect(ul.getAttribute('aria-hidden')).toBe('true')
118
+ expect(container.querySelectorAll(OPTION)).toHaveLength(0) // NOSONAR
100
119
  })
101
120
 
102
121
  it('sets aria-describedby to the shared hints container id', () => {
@@ -121,4 +140,129 @@ describe('Features — interactions', () => {
121
140
  fireEvent.blur(container.querySelector(LISTBOX)) // NOSONAR
122
141
  expect(onBlur).toHaveBeenCalled()
123
142
  })
143
+
144
+ it('calls onSelectItem with the clicked item id', () => {
145
+ const onSelectItem = jest.fn()
146
+ const { container } = render(<Features items={ITEMS} onSelectItem={onSelectItem} />)
147
+ fireEvent.click(container.querySelectorAll(OPTION)[1]) // NOSONAR
148
+ expect(onSelectItem).toHaveBeenCalledWith('f2')
149
+ })
150
+
151
+ it('does not throw on click when onSelectItem is not provided', () => {
152
+ const { container } = render(<Features items={ITEMS} />)
153
+ expect(() => fireEvent.click(container.querySelectorAll(OPTION)[0])).not.toThrow() // NOSONAR
154
+ })
155
+ })
156
+
157
+ // ─── Features + useFeatureFocus — roving tabindex wired together for real ────
158
+ //
159
+ // The tests above exercise Features in isolation (mocked onFocus/onBlur), and
160
+ // useFeatureFocus.test.js exercises the hook in isolation (onFocus/onBlur called
161
+ // directly, not via real DOM events). Neither can catch a bug where moving real focus
162
+ // between sibling options — which roving tabindex does on every arrow key — fires a
163
+ // genuine native focusin that bubbles up and re-triggers React's onFocus on the <ul>.
164
+ // Only rendering both together with real (unmocked) focus exercises that bubbling path.
165
+
166
+ const RovingHarness = ({ eventBus }) => {
167
+ const viewportRef = useRef(document.createElement('div'))
168
+ const featuresRef = useRef(null)
169
+ const { activeFeatureId, tabbableId, selectedIds, onFocus, onBlur, selectItem } = useFeatureFocus({
170
+ viewportRef, featuresRef, items: ITEMS, eventBus, hints: { subscribe: () => () => {}, dismiss: () => {} }
171
+ })
172
+ return (
173
+ <Features
174
+ ref={featuresRef} activeFeatureId={activeFeatureId} tabbableId={tabbableId} selectedIds={selectedIds}
175
+ items={ITEMS} onFocus={onFocus} onBlur={onBlur} onSelectItem={selectItem}
176
+ />
177
+ )
178
+ }
179
+
180
+ describe('Features + useFeatureFocus — roving tabindex real focus events', () => {
181
+ it('emits map:setactivefeature exactly once when arrowing to the next option', () => {
182
+ const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
183
+ const { container } = render(<RovingHarness eventBus={eb} />)
184
+ act(() => container.querySelector('[data-id="f1"]').focus())
185
+ eb.emit.mockClear()
186
+ fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
187
+ expect(eb.emit).toHaveBeenCalledTimes(1)
188
+ expect(eb.emit).toHaveBeenCalledWith('map:setactivefeature', { id: 'f2' })
189
+ })
190
+
191
+ it('moves real DOM focus to the next option on ArrowDown', () => {
192
+ const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
193
+ const { container } = render(<RovingHarness eventBus={eb} />)
194
+ act(() => container.querySelector('[data-id="f1"]').focus())
195
+ fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
196
+ expect(document.activeElement.dataset.id).toBe('f2')
197
+ })
198
+
199
+ // A real pub/sub bus, with a minimal fake "interact plugin" listening on it — mirroring what
200
+ // useMapItemList.js actually does: track the last map:setactivefeature id, and on
201
+ // map:selectfeature, mark THAT id selected and fire interact:selectionchange back. This is
202
+ // the closest reproduction of the real reported bug: Enter only ever selecting the first item
203
+ // because every arrow move was clobbering the active id back to a stale value.
204
+ const makeFakeInteractBus = () => {
205
+ const listeners = {}
206
+ let lastActiveId = null
207
+ const bus = {
208
+ on: (event, fn) => { (listeners[event] ??= []).push(fn) },
209
+ off: (event, fn) => { listeners[event] = (listeners[event] ?? []).filter(f => f !== fn) },
210
+ emit: (event, payload) => { (listeners[event] ?? []).forEach(fn => fn(payload)) }
211
+ }
212
+ bus.on('map:setactivefeature', ({ id }) => { lastActiveId = id })
213
+ bus.on('map:selectfeature', () => {
214
+ bus.emit('interact:selectionchange', { selectedMarkers: lastActiveId ? [lastActiveId] : [] })
215
+ })
216
+ return bus
217
+ }
218
+
219
+ it('selects the item that is actually focused, not the first item, after navigating and pressing Enter', () => {
220
+ const eb = makeFakeInteractBus()
221
+ const { container } = render(<RovingHarness eventBus={eb} />)
222
+ act(() => container.querySelector('[data-id="f1"]').focus())
223
+ const listbox = container.querySelector(LISTBOX)
224
+ fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // NOSONAR — moves to f2
225
+ fireEvent.keyDown(listbox, { key: 'Enter' })
226
+ const options = container.querySelectorAll(OPTION)
227
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false') // f1 — must not be the one selected
228
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') // f2 — the one actually focused
229
+ })
230
+
231
+ it('keeps navigating correctly after a selection (does not get stuck reverting to the first item)', () => {
232
+ const eb = makeFakeInteractBus()
233
+ const { container } = render(<RovingHarness eventBus={eb} />)
234
+ act(() => container.querySelector('[data-id="f1"]').focus())
235
+ const listbox = container.querySelector(LISTBOX)
236
+ fireEvent.keyDown(listbox, { key: 'ArrowDown' }) // NOSONAR — f1 -> f2
237
+ fireEvent.keyDown(listbox, { key: 'Enter' }) // select f2
238
+ fireEvent.keyDown(listbox, { key: 'ArrowUp' }) // f2 -> f1
239
+ expect(document.activeElement.dataset.id).toBe('f1')
240
+ expect(container.querySelectorAll(OPTION)[0].tabIndex).toBe(0)
241
+ })
242
+
243
+ it('selecting a marker on the map does not move the roving tabindex position — only aria-selected changes', () => {
244
+ const eb = makeFakeInteractBus()
245
+ const { container } = render(<RovingHarness eventBus={eb} />)
246
+ // Simulates repeatedly clicking a single marker on the map — never touches the list at all.
247
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f1'] }) })
248
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f2'] }) })
249
+ const options = container.querySelectorAll(OPTION)
250
+ expect(options[0].tabIndex).toBe(0) // f1 — the structural first item, unmoved
251
+ expect(options[1].tabIndex).toBe(-1)
252
+ expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
253
+ expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true') // only this changed
254
+ })
255
+
256
+ it('moves real focus to match the selected item when Tab lands somewhere else', () => {
257
+ const eb = makeFakeInteractBus()
258
+ const { container } = render(<RovingHarness eventBus={eb} />)
259
+ // f2 selected via a map click before any keyboard interaction — tabbableId stays on the
260
+ // structural first item (f1), so Tab lands there first.
261
+ act(() => { eb.emit('interact:selectionchange', { selectedMarkers: ['f2'] }) })
262
+ expect(container.querySelectorAll(OPTION)[0].tabIndex).toBe(0) // Tab will land on f1
263
+ act(() => container.querySelector('[data-id="f1"]').focus()) // simulates the Tab press
264
+ // onFocus resolves to the selected item (f2) and must move real focus there to match —
265
+ // otherwise the list's own focus/announcement would disagree with what the map highlights.
266
+ expect(document.activeElement.dataset.id).toBe('f2')
267
+ })
124
268
  })
@@ -27,7 +27,7 @@ export const Viewport = () => {
27
27
  const featuresRef = useRef(null)
28
28
 
29
29
  const { items: featureItems, multiselectable } = useFeatureItems(eventBus)
30
- const { activeFeatureId, selectedIds, onFocus: handleFeaturesFocus, onBlur: handleFeaturesBlur } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus, hints })
30
+ const { activeFeatureId, tabbableId, selectedIds, onFocus: handleFeaturesFocus, onBlur: handleFeaturesBlur, selectItem } = useFeatureFocus({ viewportRef: layoutRefs.viewportRef, featuresRef, items: featureItems, eventBus, hints })
31
31
 
32
32
  useEffect(() => {
33
33
  const handler = () => dispatch({ type: 'SET_LISTBOX_ACTIVE' })
@@ -87,14 +87,14 @@ export const Viewport = () => {
87
87
  >
88
88
  <div className='im-c-viewport__map-container' ref={mapContainerRef} aria-hidden='true' />
89
89
  <MapStatus />
90
- <div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef} aria-hidden='true'>
90
+ <div className='im-c-viewport__safezone' style={safeZoneInset} ref={layoutRefs.safeZoneRef}>
91
91
  <CrossHair />
92
92
  </div>
93
93
  <div className='im-c-viewport__markers' aria-hidden='true'>
94
94
  <Markers />
95
95
  </div>
96
96
  </div>
97
- <Features ref={featuresRef} activeFeatureId={activeFeatureId} selectedIds={selectedIds} multiselectable={multiselectable} items={featureItems} onFocus={onFeaturesFocus} onBlur={onFeaturesBlur} />
97
+ <Features ref={featuresRef} activeFeatureId={activeFeatureId} tabbableId={tabbableId} selectedIds={selectedIds} multiselectable={multiselectable} items={featureItems} onFocus={onFeaturesFocus} onBlur={onFeaturesBlur} onSelectItem={selectItem} />
98
98
  </>
99
99
  )
100
100
  }
@@ -1,3 +1,8 @@
1
+ import { isMac } from '../../utils/isMac.js'
2
+
3
+ // getInfo uses Ctrl on Windows/Linux (Alt+letter is reserved for menu mnemonics there) and Alt/Option on Mac — keep in sync with keyboardShortcuts.js's `infoKeyHtml`.
4
+ const infoModifier = isMac() ? 'Alt' : 'Ctrl'
5
+
1
6
  export const keyboardMappings = {
2
7
  keydown: {
3
8
  ArrowUp: 'panUp',
@@ -17,8 +22,8 @@ export const keyboardMappings = {
17
22
  'Alt+ArrowUp': 'highlightNextLabel',
18
23
  'Alt+ArrowDown': 'highlightNextLabel',
19
24
  'Alt+Enter': 'highlightLabelAtCenter',
20
- 'Alt+i': 'getInfo',
21
- 'Alt+I': 'getInfo',
25
+ [`${infoModifier}+i`]: 'getInfo',
26
+ [`${infoModifier}+I`]: 'getInfo',
22
27
  Escape: 'clearSelection'
23
28
  }
24
29
  }
@@ -0,0 +1,21 @@
1
+ describe('keyboardMappings', () => {
2
+ beforeEach(() => jest.resetModules())
3
+
4
+ const load = () => require('./keyboardMappings.js').keyboardMappings
5
+
6
+ it('binds getInfo to Alt+i/Alt+I on Mac', () => {
7
+ Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true })
8
+ const { keyup } = load()
9
+ expect(keyup['Alt+i']).toBe('getInfo')
10
+ expect(keyup['Alt+I']).toBe('getInfo')
11
+ expect(keyup['Ctrl+i']).toBeUndefined()
12
+ })
13
+
14
+ it('binds getInfo to Ctrl+i/Ctrl+I on non-Mac', () => {
15
+ Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
16
+ const { keyup } = load()
17
+ expect(keyup['Ctrl+i']).toBe('getInfo')
18
+ expect(keyup['Ctrl+I']).toBe('getInfo')
19
+ expect(keyup['Alt+i']).toBeUndefined()
20
+ })
21
+ })
@@ -1,6 +1,9 @@
1
1
  // src/controls/keyboardShortcuts.js
2
- const isMac = /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform)
3
- const altKeyHtml = isMac ? '<kbd>Option</kbd>' : '<kbd>Alt</kbd>'
2
+ import { isMac } from '../../utils/isMac.js'
3
+
4
+ const altKeyHtml = isMac() ? '<kbd>Option</kbd>' : '<kbd>Alt</kbd>'
5
+ // getInfo uses Ctrl on Windows/Linux instead of Alt, which is reserved there for menu mnemonics.
6
+ const infoKeyHtml = isMac() ? '<kbd>Option</kbd>' : '<kbd>Ctrl</kbd>'
4
7
 
5
8
  export const coreShortcuts = [
6
9
  {
@@ -10,6 +13,17 @@ export const coreShortcuts = [
10
13
  context: 'global',
11
14
  enabled: true
12
15
  },
16
+ {
17
+ id: 'getInfo',
18
+ title: 'Get current location and visible area',
19
+ command: `${infoKeyHtml} + <kbd>I</kbd>`,
20
+ enabled: true,
21
+ // No visual equivalent, so hide the row from sighted users but keep it discoverable via assistive tech.
22
+ visuallyHidden: true,
23
+ // Only needs getCenter/getZoom, which every map provider implements, so no adapter needs to declare it.
24
+ mapProviderAgnostic: true,
25
+ requiredConfig: ['reverseGeocodeProvider']
26
+ },
13
27
  {
14
28
  id: 'moveLarge',
15
29
  title: 'Move in large steps',
@@ -21,4 +21,25 @@ describe('keyboardShortcuts', () => {
21
21
  expect(s.command).toContain('<kbd>Alt</kbd>')
22
22
  })
23
23
  })
24
+
25
+ it('marks getInfo as visuallyHidden, map-provider-agnostic and requiring reverseGeocodeProvider', () => {
26
+ const shortcuts = load()
27
+ const getInfo = shortcuts.find(s => s.id === 'getInfo')
28
+ expect(getInfo).toMatchObject({
29
+ visuallyHidden: true,
30
+ mapProviderAgnostic: true,
31
+ requiredConfig: ['reverseGeocodeProvider']
32
+ })
33
+ })
34
+
35
+ it('uses Ctrl for getInfo on non-Mac and Option on Mac', () => {
36
+ Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true })
37
+ let getInfo = load().find(s => s.id === 'getInfo')
38
+ expect(getInfo.command).toContain('<kbd>Option</kbd>')
39
+
40
+ jest.resetModules()
41
+ Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
42
+ getInfo = load().find(s => s.id === 'getInfo')
43
+ expect(getInfo.command).toContain('<kbd>Ctrl</kbd>')
44
+ })
24
45
  })
@@ -32,7 +32,19 @@ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition
32
32
  dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: true } })
33
33
  }
34
34
 
35
+ // Fully hidden — display:none, not just faded — rather than staying in the accessibility
36
+ // tree while invisible. An earlier version kept it ax-tree-present here so Voice Control
37
+ // could re-summon it by name, but that made "Show Names" put a "Target" label over
38
+ // nothing whenever it was genuinely hidden, which is more confusing than helpful. Voice
39
+ // Control's real route back is opening MoveControls (a normal, always-visible, always-named
40
+ // button) — see isMoveControlsOpen below — so the crosshair itself doesn't need to stay
41
+ // discoverable while it's not actually there. Blocked outright while MoveControls is open:
42
+ // that panel is a deliberate, explicit "I can't drag/pinch this map" signal from the user,
43
+ // which should win over whatever mouse-vs-touch/keyboard inference would otherwise decide.
35
44
  crossHair.hide = () => {
45
+ if (crossHair.isMoveControlsOpen) {
46
+ return
47
+ }
36
48
  el.style.display = 'none'
37
49
  dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
38
50
  }
@@ -55,7 +67,7 @@ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition
55
67
 
56
68
  export const useCrossHair = () => {
57
69
  const { mapProvider } = useConfig()
58
- const { safeZoneInset } = useApp()
70
+ const { safeZoneInset, expandedButtons } = useApp()
59
71
  const { eventBus } = useService()
60
72
  const { crossHair, dispatch, mapSize } = useMap()
61
73
 
@@ -98,6 +110,22 @@ export const useCrossHair = () => {
98
110
  }
99
111
  }, [mapSize])
100
112
 
113
+ // A plain property on the shared crossHair object, not dispatched state — every owner
114
+ // (draw's per-adapter modes, interact's useCrossHairVisibility) already holds this same
115
+ // object, so mutating it here is all that's needed for hide()'s own guard above to see the
116
+ // current value live, however long ago it was last set. This alone never shows or hides
117
+ // anything by itself — it only ever blocks a hide() call already in flight — so it's safe to
118
+ // run unconditionally even when no plugin that cares about it is loaded at all.
119
+ //
120
+ // Deliberately NOT a useEffect: this component's own effects and interact's/draw's own
121
+ // updateCrossHair effects both react to the same expandedButtons change, and React gives no
122
+ // ordering guarantee between two different components' effects — if the consumer's effect
123
+ // (which decides to call hide()) ran before this one updated the flag, hide()'s guard would
124
+ // read the stale value and incorrectly block itself. Setting it directly in the render body
125
+ // (matches DrawInit.jsx's shouldShowCrosshairRef convention) guarantees it's current before
126
+ // ANY component's effects run, since React finishes rendering the whole tree first.
127
+ crossHair.isMoveControlsOpen = expandedButtons?.has('moveControls')
128
+
101
129
  return {
102
130
  crossHair,
103
131
  crossHairRef
@@ -35,7 +35,7 @@ describe('useCrossHair', () => {
35
35
  eventBus.off = jest.fn()
36
36
 
37
37
  useConfig.mockReturnValue({ mapProvider: mockMapProvider })
38
- useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 } })
38
+ useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set() })
39
39
  useService.mockReturnValue({ eventBus: mockEventBus })
40
40
  useMap.mockReturnValue({
41
41
  crossHair: mockCrossHair,
@@ -98,6 +98,22 @@ describe('useCrossHair', () => {
98
98
  expect(mockElement.style.display).toBe('none')
99
99
  })
100
100
 
101
+ it('hide() is a no-op while isMoveControlsOpen is set', () => {
102
+ setup()
103
+ act(() => mockCrossHair.show())
104
+ mockCrossHair.isMoveControlsOpen = true
105
+
106
+ act(() => mockCrossHair.hide())
107
+ expect(mockElement.style.display).toBe('block')
108
+ expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
109
+ })
110
+
111
+ it('keeps crossHair.isMoveControlsOpen synced with expandedButtons, set synchronously during render (not a useEffect) so it is never stale relative to another component\'s own effect reacting to the same change', () => {
112
+ useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set(['moveControls']) })
113
+ setup()
114
+ expect(mockCrossHair.isMoveControlsOpen).toBe(true)
115
+ })
116
+
101
117
  it('setStyle updates state', () => {
102
118
  setup()
103
119
  act(() => mockCrossHair.setStyle('highlighted'))
@@ -5,6 +5,12 @@ const getNavigatedId = (id, key, items) => {
5
5
  if (!items.length) {
6
6
  return id
7
7
  }
8
+ if (key === 'Home') {
9
+ return items[0].id
10
+ }
11
+ if (key === 'End') {
12
+ return items[items.length - 1].id
13
+ }
8
14
  const idx = items.findIndex(item => item.id === id)
9
15
  if (key === 'ArrowDown') {
10
16
  return idx === -1 ? items[0].id : items[Math.min(idx + 1, items.length - 1)].id
@@ -24,10 +30,25 @@ const resolveEntryId = (items, lastActiveId, selectedIds) => {
24
30
  return items[0].id
25
31
  }
26
32
 
33
+ // Roving tabindex: moves real focus to the option matching id, found via data-id (avoids
34
+ // CSS-escaping issues a querySelector would have with arbitrary ids). Flags
35
+ // isInternalFocusMoveRef around the .focus() call so onFocus/onBlur can ignore this as an
36
+ // internal move, not a real widget entry/exit — event.relatedTarget can't be used for that:
37
+ // preact/compat (react is aliased to it in this app's build) doesn't populate it on synthetic
38
+ // focus/blur events, so it's silently unreliable outside tests (real React + jsdom).
39
+ const focusOption = (listboxEl, id, isInternalFocusMoveRef) => {
40
+ const el = Array.from(listboxEl?.children ?? []).find(li => li.dataset.id === String(id))
41
+ if (!el) {
42
+ return
43
+ }
44
+ isInternalFocusMoveRef.current = true
45
+ el.focus({ preventScroll: true })
46
+ isInternalFocusMoveRef.current = false
47
+ }
48
+
27
49
  /**
28
50
  * Keeps local selectedIds in sync with interact:selectionchange, and mirrors
29
- * MAP_SET_ACTIVE_FEATURE back into React state so aria-activedescendant stays current.
30
- * Also tracks the last non-null active id so it can be restored on re-focus.
51
+ * MAP_SET_ACTIVE_FEATURE back into React state so the roving tabindex position stays current.
31
52
  */
32
53
  function useEventBusListeners ({ eventBus, lastActiveIdRef, setActiveFeatureId, setSelectedIds }) {
33
54
  useEffect(() => {
@@ -53,12 +74,10 @@ function useEventBusListeners ({ eventBus, lastActiveIdRef, setActiveFeatureId,
53
74
  }
54
75
 
55
76
  /**
56
- * Revalidates aria-activedescendant when the item list changes while the listbox is focused.
57
- * If the current active item is no longer in the list (e.g. panned off screen), picks a new one
58
- * using ARIA priority order: first selected → last active position → first item.
59
- * Reads eventBus and selectedIds via refs to avoid spurious re-runs on selection change.
77
+ * Re-picks the active item (ARIA priority order) when it drops out of the item list while
78
+ * focused e.g. panned off screen and moves real focus to it.
60
79
  */
61
- function useItemsRevalidation ({ items, eventBus, isFocusedRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId }) {
80
+ function useItemsRevalidation ({ items, eventBus, isFocusedRef, featuresRef, isInternalFocusMoveRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId }) {
62
81
  useEffect(() => {
63
82
  if (!isFocusedRef.current) {
64
83
  return
@@ -75,16 +94,18 @@ function useItemsRevalidation ({ items, eventBus, isFocusedRef, lastActiveIdRef,
75
94
  lastActiveIdRef.current = nextId
76
95
  setActiveFeatureId(nextId)
77
96
  eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: nextId })
97
+ focusOption(featuresRef.current, nextId, isInternalFocusMoveRef)
78
98
  }, [items]) // NOSONAR — eventBus/selectedIds consumed via refs to avoid spurious re-runs on selection change
79
99
  }
80
100
 
81
101
  /**
82
102
  * Attaches a keydown listener to the listbox element for ARIA keyboard navigation:
83
- * - ArrowUp/ArrowDown — move the active item, emitting MAP_SET_ACTIVE_FEATURE
103
+ * - ArrowUp/ArrowDown — move the active item, moving real focus to it (roving tabindex)
104
+ * - Home/End — jump the active item to the first/last option
84
105
  * - Enter/Space — confirm selection, emitting MAP_SELECT_FEATURE
85
106
  * - Escape — return focus to the map viewport
86
107
  */
87
- function useKeyboardNavigation ({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, hints, currentHintRef }) {
108
+ function useKeyboardNavigation ({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, isInternalFocusMoveRef, hints, currentHintRef }) {
88
109
  useEffect(() => {
89
110
  const listboxEl = featuresRef.current
90
111
  if (!listboxEl) {
@@ -99,13 +120,14 @@ function useKeyboardNavigation ({ featuresRef, viewportRef, items, eventBus, act
99
120
  } else {
100
121
  viewportRef.current?.focus()
101
122
  }
102
- } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
123
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
103
124
  event.preventDefault()
104
125
  event.stopPropagation()
105
126
  const newId = getNavigatedId(activeFeatureIdRef.current, event.key, items)
106
127
  lastActiveIdRef.current = newId
107
128
  setActiveFeatureId(newId)
108
129
  eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: newId })
130
+ focusOption(listboxEl, newId, isInternalFocusMoveRef)
109
131
  } else if (event.key === 'Enter' || event.key === ' ') {
110
132
  event.preventDefault()
111
133
  event.stopPropagation()
@@ -120,9 +142,8 @@ function useKeyboardNavigation ({ featuresRef, viewportRef, items, eventBus, act
120
142
  }
121
143
 
122
144
  /**
123
- * Clears listbox focus whenever the user interacts with the map via pointer (mouse or touch).
124
- * Any pointerdown outside the listbox element moves focus to the viewport, removing the
125
- * visible focus ring from the listbox without dropping focus to an unknown location.
145
+ * Returns focus to the viewport when the user interacts with the map via pointer while the
146
+ * listbox is focused clears its focus ring without dropping focus to nowhere.
126
147
  */
127
148
  function useMapInteractionBlur ({ viewportRef, featuresRef, isFocusedRef }) {
128
149
  useEffect(() => {
@@ -141,18 +162,12 @@ function useMapInteractionBlur ({ viewportRef, featuresRef, isFocusedRef }) {
141
162
  }
142
163
 
143
164
  /**
144
- * Manages ARIA listbox focus state for the keyboard-accessible feature list.
145
- *
146
- * On focus, sets aria-activedescendant following ARIA priority order:
147
- * 1. First selected item (if any)
148
- * 2. Last active position (if still in the list)
149
- * 3. First item (fallback)
150
- *
151
- * On blur, clears the active descendant. Revalidates automatically when the item list
152
- * changes (e.g. after a map pan) so the active id never points to a stale item.
165
+ * Manages roving-tabindex focus state for the keyboard-accessible feature list. On focus, sets
166
+ * activeFeatureId via ARIA priority order (see resolveEntryId); on blur, clears it. Revalidates
167
+ * when the item list changes (e.g. after a map pan) so it never points to a stale item.
153
168
  *
154
169
  * @param {{ viewportRef: React.RefObject, featuresRef: React.RefObject, items: Array, eventBus: object }} params
155
- * @returns {{ activeFeatureId: string|null, selectedIds: string[], onFocus: Function, onBlur: Function }}
170
+ * @returns {{ activeFeatureId: string|null, tabbableId: string|null, selectedIds: string[], onFocus: Function, onBlur: Function, selectItem: Function }}
156
171
  */
157
172
  export function useFeatureFocus ({ viewportRef, featuresRef, items = [], eventBus, hints }) {
158
173
  const [activeFeatureId, setActiveFeatureId] = useState(null)
@@ -163,6 +178,7 @@ export function useFeatureFocus ({ viewportRef, featuresRef, items = [], eventBu
163
178
  const activeFeatureIdRef = useRef(null) // always-current for keydown closure
164
179
  const selectedIdsRef = useRef([]) // always-current for items-change effect
165
180
  const currentHintRef = useRef(null)
181
+ const isInternalFocusMoveRef = useRef(false) // true only while focusOption()'s own .focus() call is in flight
166
182
 
167
183
  useEffect(() => {
168
184
  return hints.subscribe((hint) => {
@@ -170,30 +186,72 @@ export function useFeatureFocus ({ viewportRef, featuresRef, items = [], eventBu
170
186
  })
171
187
  }, [hints])
172
188
 
173
- useEffect(() => { activeFeatureIdRef.current = activeFeatureId }, [activeFeatureId])
174
- useEffect(() => { selectedIdsRef.current = selectedIds }, [selectedIds])
189
+ // Always-current mirrors for values read from event-listener closures (not React re-renders)
190
+ // assigned directly during render, same convention as useVisibleGeometry.js's latestRef.
191
+ activeFeatureIdRef.current = activeFeatureId
192
+ selectedIdsRef.current = selectedIds
175
193
 
176
194
  useEventBusListeners({ eventBus, lastActiveIdRef, setActiveFeatureId, setSelectedIds })
177
- useItemsRevalidation({ items, eventBus, isFocusedRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId })
178
- useKeyboardNavigation({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, hints, currentHintRef })
195
+ useItemsRevalidation({ items, eventBus, isFocusedRef, featuresRef, isInternalFocusMoveRef, lastActiveIdRef, activeFeatureIdRef, selectedIdsRef, setActiveFeatureId })
196
+ useKeyboardNavigation({ featuresRef, viewportRef, items, eventBus, activeFeatureIdRef, lastActiveIdRef, setActiveFeatureId, isInternalFocusMoveRef, hints, currentHintRef })
179
197
  useMapInteractionBlur({ viewportRef, featuresRef, isFocusedRef })
180
198
 
199
+ // Resting roving-tabindex position — where Tab lands before the list has ever had real focus.
200
+ // activeFeatureId takes priority once the list is actually focused. Deliberately never
201
+ // selection-driven (unlike onFocus's own resolution below) — sticks to the last established
202
+ // keyboard position, or the first item if there isn't one yet. "Prefer the selected item" is
203
+ // an onFocus-only concern (a real Tab-in); if it applied here too, any selection change made
204
+ // elsewhere (e.g. clicking a marker on the map, which never touches lastActiveIdRef) would
205
+ // keep relocating tabIndex to follow it, even though nothing about keyboard state changed.
206
+ let tabbableId = null
207
+ if (items.length) {
208
+ const hasEstablishedPosition = lastActiveIdRef.current && items.some(item => item.id === lastActiveIdRef.current)
209
+ tabbableId = hasEstablishedPosition ? lastActiveIdRef.current : items[0].id
210
+ }
211
+
181
212
  const onFocus = () => {
182
213
  isFocusedRef.current = true
214
+ // Ignore focus moving between sibling options (roving tabindex) — the keydown/selectItem
215
+ // path that moved it already resolved and emitted the correct id; re-resolving here would
216
+ // clobber that emit.
217
+ if (isInternalFocusMoveRef.current) {
218
+ return
219
+ }
183
220
  if (!items.length) {
184
221
  return
185
222
  }
223
+ // A real Tab-in always prefers a selected item over the remembered position (native listbox
224
+ // behaviour) — can't reuse tabbableId here, which deliberately does the opposite (sticks to
225
+ // the remembered position, ignoring selection) once one is established.
186
226
  const id = resolveEntryId(items, lastActiveIdRef.current, selectedIds)
187
227
  lastActiveIdRef.current = id
188
228
  setActiveFeatureId(id)
189
229
  eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id })
230
+ // Real focus may have landed on a different option than this resolves to — tabbableId (which
231
+ // decided where Tab lands) can legitimately disagree with resolveEntryId's own priority (e.g.
232
+ // Tab lands on the structural first item, but a different item is selected and takes
233
+ // priority here). Move real focus to match so it never disagrees with activeFeatureId.
234
+ focusOption(featuresRef.current, id, isInternalFocusMoveRef)
190
235
  }
191
236
 
192
237
  const onBlur = () => {
238
+ // Ignore focus moving between sibling options (roving tabindex) — not a real exit.
239
+ if (isInternalFocusMoveRef.current) {
240
+ return
241
+ }
193
242
  isFocusedRef.current = false
194
243
  setActiveFeatureId(null)
195
244
  eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id: null })
196
245
  }
197
246
 
198
- return { activeFeatureId, selectedIds, onFocus, onBlur }
247
+ // Mirrors keyboard Enter/Space: make the clicked item active, focus it, then confirm selection.
248
+ const selectItem = (id) => {
249
+ lastActiveIdRef.current = id
250
+ setActiveFeatureId(id)
251
+ eventBus?.emit(EVENTS.MAP_SET_ACTIVE_FEATURE, { id })
252
+ focusOption(featuresRef.current, id, isInternalFocusMoveRef)
253
+ eventBus?.emit(EVENTS.MAP_SELECT_FEATURE)
254
+ }
255
+
256
+ return { activeFeatureId, tabbableId, selectedIds, onFocus, onBlur, selectItem }
199
257
  }