@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/keyboard-shortcut-definition.md +102 -0
- package/docs/api.md +2 -0
- package/docs/building-a-plugin.md +1 -0
- package/docs/plugins/draw.md +1 -1
- package/docs/plugins/plugin-manifest.md +9 -0
- package/package.json +1 -1
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/src/Frame.jsx +28 -2
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/src/registry/dataset.js +8 -12
- package/plugins/datasets/src/registry/dataset.test.js +17 -0
- package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
- package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
- package/plugins/draw/src/DrawInit.jsx +23 -13
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +15 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +18 -0
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +9 -0
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +12 -1
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +18 -0
- package/plugins/draw/src/manifest.js +4 -1
- package/plugins/draw/src/manifest.test.js +19 -7
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/events.js +6 -2
- package/plugins/interact/src/events.test.js +32 -0
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +8 -3
- package/plugins/interact/src/hooks/useMapItemList.js +41 -12
- package/plugins/interact/src/hooks/useMapItemList.test.js +68 -3
- package/plugins/interact/src/utils/spatial.js +24 -1
- package/plugins/interact/src/utils/spatial.test.js +26 -1
- package/plugins/map-key/dist/css/index.css +1 -1
- package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
- package/plugins/map-key/dist/umd/index.js +1 -1
- package/plugins/map-key/src/components/Key/Key.jsx +3 -4
- package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
- package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
- package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
- package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
- package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
- package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
- package/plugins/map-key/src/manifest.js +1 -1
- package/plugins/map-key/src/utils/groupStyles.js +7 -0
- package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
- package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
- package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
- package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
- package/plugins/menu/dist/umd/index.js +1 -1
- package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
- package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
- package/plugins/menu/src/initialise/createMenu.js +3 -1
- package/plugins/menu/src/initialise/createMenu.test.js +20 -5
- package/plugins/menu/src/initialise/updateUrl.js +44 -0
- package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
- package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
- package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
- package/plugins/menu/src/reducers/pluginState.js +1 -6
- package/plugins/menu/src/reducers/pluginState.test.js +0 -25
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/manifest.js +5 -9
- package/plugins/search/src/manifest.test.js +5 -0
- package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
- package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
- package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
- package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
- package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
- package/providers/maplibre/src/defaults.test.js +26 -0
- package/src/App/components/CrossHair/CrossHair.jsx +19 -11
- package/src/App/components/CrossHair/CrossHair.module.scss +33 -12
- package/src/App/components/CrossHair/CrossHair.test.jsx +35 -11
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
- package/src/App/components/MapButton/MapButton.jsx +19 -7
- package/src/App/components/MapButton/MapButton.test.jsx +42 -4
- package/src/App/components/Markers/SymbolMarker.jsx +2 -3
- package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
- package/src/App/components/Panel/Panel.jsx +19 -20
- package/src/App/components/Panel/Panel.module.scss +5 -0
- package/src/App/components/Panel/Panel.test.jsx +69 -4
- package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
- package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
- package/src/App/components/Viewport/Features.jsx +18 -4
- package/src/App/components/Viewport/Features.module.scss +4 -4
- package/src/App/components/Viewport/Features.test.jsx +155 -11
- package/src/App/components/Viewport/Viewport.jsx +3 -3
- package/src/App/controls/keyboardMappings.js +7 -2
- package/src/App/controls/keyboardMappings.test.js +21 -0
- package/src/App/controls/keyboardShortcuts.js +16 -2
- package/src/App/controls/keyboardShortcuts.test.js +21 -0
- package/src/App/hooks/useCrossHairAPI.js +29 -1
- package/src/App/hooks/useCrossHairAPI.test.js +17 -1
- package/src/App/hooks/useFeatureFocus.js +86 -28
- package/src/App/hooks/useFeatureFocus.test.js +167 -0
- package/src/App/hooks/useFeatureItems.js +1 -1
- package/src/App/hooks/useInterfaceAPI.js +2 -1
- package/src/App/hooks/useInterfaceAPI.test.js +9 -0
- package/src/App/hooks/useKeyboardShortcuts.js +66 -35
- package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
- package/src/App/hooks/useModalPanelBehaviour.js +48 -34
- package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
- package/src/App/layout/Layout.jsx +4 -2
- package/src/App/layout/Layout.test.jsx +21 -0
- package/src/App/layout/layout.module.scss +7 -1
- package/src/App/registry/keyboardShortcutRegistry.js +2 -2
- package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
- package/src/App/renderer/HtmlElementHost.jsx +12 -32
- package/src/App/renderer/mapButtons.js +4 -0
- package/src/App/renderer/mapButtons.test.js +20 -0
- package/src/App/renderer/mapPanels.js +33 -53
- package/src/App/renderer/mapPanels.test.js +29 -4
- package/src/App/renderer/slotHelpers.js +33 -0
- package/src/App/renderer/slotHelpers.test.js +79 -2
- package/src/InteractiveMap/InteractiveMap.js +13 -2
- package/src/InteractiveMap/InteractiveMap.test.js +33 -0
- package/src/config/appConfig.js +2 -2
- package/src/types.js +40 -0
- package/src/utils/getPanelElementId.js +4 -0
- package/src/utils/getPanelElementId.test.js +11 -0
- package/src/utils/getPanelRole.js +22 -0
- 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('
|
|
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
|
-
|
|
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('
|
|
74
|
-
const { container } = render(<Features items={ITEMS} />)
|
|
75
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
*
|
|
57
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
124
|
-
*
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
}
|