@defra/interactive-map 0.0.43-alpha → 0.0.44-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.md +16 -8
- package/docs/plugins/draw.md +1 -1
- package/package.json +1 -1
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
- 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/css/index.css +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/components/LayersMenu/Layers.module.scss +1 -0
- package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
- package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -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/esm/index.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/dist/umd/index.js +1 -1
- package/plugins/draw/src/DrawInit.jsx +31 -26
- package/plugins/draw/src/DrawInit.test.jsx +55 -12
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
- package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
- package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +8 -2
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +35 -0
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
- package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
- package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
- package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
- package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +31 -17
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
- package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +32 -57
- package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +14 -13
- package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
- package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
- package/plugins/draw/src/events.js +4 -4
- package/plugins/draw/src/events.test.js +1 -1
- package/plugins/draw/src/hooks/useSpatialList.js +180 -0
- package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
- package/plugins/draw/src/utils/spatial.js +1 -31
- package/plugins/draw/src/utils/spatial.test.js +1 -24
- 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/InteractInit.jsx +2 -2
- package/plugins/interact/src/InteractInit.test.js +3 -3
- package/plugins/interact/src/events.js +5 -0
- package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
- package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +4 -4
- package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
- package/plugins/interact/src/hooks/{useMapItemList.js → useSpatialList.js} +87 -52
- package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +152 -71
- package/plugins/interact/src/manifest.js +12 -1
- package/plugins/interact/src/manifest.test.js +20 -0
- package/plugins/interact/src/utils/spatial.js +1 -1
- package/providers/beta/openlayers/src/utils/queryFeatures.js +1 -1
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/utils/labels.js +2 -2
- package/providers/maplibre/src/utils/labels.test.js +5 -5
- package/providers/maplibre/src/utils/spatial.js +0 -57
- package/providers/maplibre/src/utils/spatial.test.js +0 -27
- package/src/App/components/CrossHair/CrossHair.jsx +10 -4
- package/src/App/components/CrossHair/CrossHair.module.scss +16 -12
- package/src/App/components/CrossHair/CrossHair.test.jsx +9 -4
- package/src/App/components/Hints/Hints.jsx +3 -3
- package/src/App/components/Hints/Hints.test.jsx +3 -3
- package/src/App/components/MapButton/MapButton.module.scss +1 -2
- package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
- package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
- package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
- package/src/App/components/Markers/Markers.jsx +2 -2
- package/src/App/components/Markers/Markers.test.jsx +3 -3
- package/src/App/components/Markers/SymbolMarker.test.jsx +2 -2
- package/src/App/components/Viewport/MapStatus.jsx +1 -1
- package/src/App/components/Viewport/SpatialList.jsx +47 -0
- package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +3 -3
- package/src/App/components/Viewport/{Features.test.jsx → SpatialList.test.jsx} +60 -43
- package/src/App/components/Viewport/Viewport.jsx +20 -10
- package/src/App/components/Viewport/Viewport.test.jsx +18 -2
- package/src/App/hooks/useCrossHairAPI.js +5 -5
- package/src/App/hooks/useCrossHairAPI.test.js +5 -5
- package/src/App/hooks/useHintsAPI.js +5 -5
- package/src/App/hooks/useSpatialListFocus.js +296 -0
- package/src/App/hooks/{useFeatureFocus.test.js → useSpatialListFocus.test.js} +318 -163
- package/src/App/hooks/useSpatialListItems.js +51 -0
- package/src/App/hooks/useSpatialListItems.test.js +217 -0
- package/src/App/initialiseApp.js +6 -3
- package/src/App/initialiseApp.test.js +11 -0
- package/src/App/registry/spatialListRegistry.js +104 -0
- package/src/App/registry/spatialListRegistry.test.js +218 -0
- package/src/App/store/AppProvider.jsx +3 -2
- package/src/config/appConfig.js +14 -14
- package/src/config/appConfig.test.js +19 -19
- package/src/config/defaults.js +2 -2
- package/src/config/events.js +8 -8
- package/src/scss/main.scss +2 -2
- package/src/test-utils.js +8 -0
- package/src/types.js +11 -9
- package/src/utils/findNearestItemInDirection.js +29 -0
- package/src/utils/findNearestItemInDirection.test.js +67 -0
- package/src/utils/globalAltShortcuts.js +12 -0
- package/src/utils/globalAltShortcuts.test.js +27 -0
- package/src/utils/spatialNavigate.js +49 -0
- package/src/utils/spatialNavigate.test.js +42 -0
- package/src/App/components/Viewport/Features.jsx +0 -49
- package/src/App/hooks/useFeatureFocus.js +0 -257
- package/src/App/hooks/useFeatureItems.js +0 -39
- package/src/App/hooks/useFeatureItems.test.js +0 -155
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useRef } from 'react'
|
|
2
2
|
import { render, fireEvent, act } from '@testing-library/react'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { SpatialList } from './SpatialList.jsx'
|
|
4
|
+
import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
|
|
5
5
|
import { useConfig } from '../../store/configContext.js'
|
|
6
6
|
|
|
7
7
|
jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
|
|
@@ -19,145 +19,162 @@ beforeEach(() => {
|
|
|
19
19
|
useConfig.mockReturnValue({ id: APP_ID })
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
// ───
|
|
22
|
+
// ─── SpatialList — rendering ─────────────────────────────────────────────────────
|
|
23
23
|
|
|
24
|
-
describe('
|
|
24
|
+
describe('SpatialList — rendering', () => {
|
|
25
25
|
it('renders a listbox with the correct id', () => {
|
|
26
|
-
const { container } = render(<
|
|
27
|
-
expect(container.querySelector(`#${APP_ID}-
|
|
26
|
+
const { container } = render(<SpatialList />)
|
|
27
|
+
expect(container.querySelector(`#${APP_ID}-spatial-list`)).toBeTruthy()
|
|
28
28
|
expect(container.querySelector(LISTBOX)).toBeTruthy() // NOSONAR
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
it('renders no options when items is empty', () => {
|
|
32
|
-
const { container } = render(<
|
|
32
|
+
const { container } = render(<SpatialList />)
|
|
33
33
|
expect(container.querySelectorAll('[role="option"]')).toHaveLength(0) // NOSONAR
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
+
it('defaults aria-label to "Map features" when no label prop is given', () => {
|
|
37
|
+
const { container } = render(<SpatialList />)
|
|
38
|
+
expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Map features')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('uses whatever label prop is given, e.g. for draw\'s edit-mode vertices', () => {
|
|
42
|
+
const { container } = render(<SpatialList label='Shape points' />)
|
|
43
|
+
expect(container.querySelector(LISTBOX).getAttribute('aria-label')).toBe('Shape points')
|
|
44
|
+
})
|
|
45
|
+
|
|
36
46
|
it('renders one option per item with correct id, data-id and label', () => {
|
|
37
|
-
const { container } = render(<
|
|
47
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
38
48
|
const options = container.querySelectorAll(OPTION)
|
|
39
49
|
expect(options).toHaveLength(2)
|
|
40
|
-
expect(options[0].getAttribute('id')).toBe(`${APP_ID}-
|
|
50
|
+
expect(options[0].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f1`)
|
|
41
51
|
expect(options[0].dataset.id).toBe('f1')
|
|
42
52
|
expect(options[0].textContent).toBe('Feature One')
|
|
43
|
-
expect(options[1].getAttribute('id')).toBe(`${APP_ID}-
|
|
53
|
+
expect(options[1].getAttribute('id')).toBe(`${APP_ID}-spatial-list-item-f2`)
|
|
44
54
|
expect(options[1].dataset.id).toBe('f2')
|
|
45
55
|
expect(options[1].textContent).toBe('Feature Two')
|
|
46
56
|
})
|
|
47
57
|
|
|
48
58
|
it('positions an option at its x/y when provided, for coordinate-based AT overlays (e.g. Voice Control)', () => {
|
|
49
59
|
const itemsWithPosition = [{ id: 'f1', label: 'Feature One', x: 120, y: 340 }]
|
|
50
|
-
const { container } = render(<
|
|
60
|
+
const { container } = render(<SpatialList items={itemsWithPosition} />)
|
|
51
61
|
const option = container.querySelector(OPTION)
|
|
52
62
|
expect(option.style.left).toBe('120px')
|
|
53
63
|
expect(option.style.top).toBe('340px')
|
|
54
64
|
})
|
|
55
65
|
|
|
56
66
|
it('does not set left/top when an item has no x/y', () => {
|
|
57
|
-
const { container } = render(<
|
|
67
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
58
68
|
const option = container.querySelector(OPTION)
|
|
59
69
|
expect(option.style.left).toBe('')
|
|
60
70
|
expect(option.style.top).toBe('')
|
|
61
71
|
})
|
|
62
72
|
|
|
63
73
|
it('sets aria-selected on items present in selectedIds', () => {
|
|
64
|
-
const { container } = render(<
|
|
74
|
+
const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1']} />)
|
|
65
75
|
const options = container.querySelectorAll(OPTION)
|
|
66
76
|
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
67
77
|
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
68
78
|
})
|
|
69
79
|
|
|
70
80
|
it('sets aria-selected on multiple items when selectedIds has multiple entries', () => {
|
|
71
|
-
const { container } = render(<
|
|
81
|
+
const { container } = render(<SpatialList items={ITEMS} selectedIds={['f1', 'f2']} />)
|
|
72
82
|
const options = container.querySelectorAll(OPTION)
|
|
73
83
|
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
74
84
|
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'true')
|
|
75
85
|
})
|
|
76
86
|
|
|
77
|
-
it('does not set aria-selected from
|
|
78
|
-
const { container } = render(<
|
|
87
|
+
it('does not set aria-selected from activeItemId alone', () => {
|
|
88
|
+
const { container } = render(<SpatialList items={ITEMS} activeItemId='f1' />)
|
|
79
89
|
const options = container.querySelectorAll(OPTION)
|
|
80
90
|
expect(options[0]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
81
91
|
expect(options[1]).toHaveAttribute(ARIA_SELECTED, 'false')
|
|
82
92
|
})
|
|
83
93
|
|
|
84
94
|
it('gives the active item tabIndex 0 and every other item tabIndex -1 (roving tabindex)', () => {
|
|
85
|
-
const { container } = render(<
|
|
95
|
+
const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' />)
|
|
86
96
|
const options = container.querySelectorAll(OPTION)
|
|
87
97
|
expect(options[0].getAttribute('tabIndex')).toBe('-1')
|
|
88
98
|
expect(options[1].getAttribute('tabIndex')).toBe('0')
|
|
89
99
|
})
|
|
90
100
|
|
|
91
|
-
it('falls back to tabbableId for roving tabindex when
|
|
92
|
-
const { container } = render(<
|
|
101
|
+
it('falls back to tabbableId for roving tabindex when activeItemId is absent', () => {
|
|
102
|
+
const { container } = render(<SpatialList items={ITEMS} tabbableId='f1' />)
|
|
93
103
|
const options = container.querySelectorAll(OPTION)
|
|
94
104
|
expect(options[0].getAttribute('tabIndex')).toBe('0')
|
|
95
105
|
expect(options[1].getAttribute('tabIndex')).toBe('-1')
|
|
96
106
|
})
|
|
97
107
|
|
|
108
|
+
it('gives every item tabIndex -1 when focusable is false, even the active one — never a real Tab stop', () => {
|
|
109
|
+
const { container } = render(<SpatialList items={ITEMS} activeItemId='f2' focusable={false} />)
|
|
110
|
+
const options = container.querySelectorAll(OPTION)
|
|
111
|
+
expect(options[0].getAttribute('tabIndex')).toBe('-1')
|
|
112
|
+
expect(options[1].getAttribute('tabIndex')).toBe('-1')
|
|
113
|
+
})
|
|
114
|
+
|
|
98
115
|
it('sets aria-multiselectable when multiselectable is true', () => {
|
|
99
|
-
const { container } = render(<
|
|
116
|
+
const { container } = render(<SpatialList items={ITEMS} multiselectable />)
|
|
100
117
|
expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBe('true') // NOSONAR
|
|
101
118
|
})
|
|
102
119
|
|
|
103
120
|
it('omits aria-multiselectable when multiselectable is false', () => {
|
|
104
|
-
const { container } = render(<
|
|
121
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
105
122
|
expect(container.querySelector(LISTBOX).getAttribute('aria-multiselectable')).toBeNull() // NOSONAR
|
|
106
123
|
})
|
|
107
124
|
|
|
108
125
|
it('is not aria-hidden when items are present', () => {
|
|
109
|
-
const { container } = render(<
|
|
126
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
110
127
|
const ul = container.querySelector(LISTBOX) // NOSONAR
|
|
111
128
|
expect(ul.getAttribute('aria-hidden')).toBeNull()
|
|
112
129
|
})
|
|
113
130
|
|
|
114
131
|
it('is aria-hidden and has no options when items is empty', () => {
|
|
115
|
-
const { container } = render(<
|
|
132
|
+
const { container } = render(<SpatialList />)
|
|
116
133
|
const ul = container.querySelector(LISTBOX) // NOSONAR
|
|
117
134
|
expect(ul.getAttribute('aria-hidden')).toBe('true')
|
|
118
135
|
expect(container.querySelectorAll(OPTION)).toHaveLength(0) // NOSONAR
|
|
119
136
|
})
|
|
120
137
|
|
|
121
138
|
it('sets aria-describedby to the shared hints container id', () => {
|
|
122
|
-
const { container } = render(<
|
|
139
|
+
const { container } = render(<SpatialList />)
|
|
123
140
|
expect(container.querySelector(LISTBOX).getAttribute('aria-describedby')).toBe(`${APP_ID}-keyboard-desc`) // NOSONAR
|
|
124
141
|
})
|
|
125
142
|
})
|
|
126
143
|
|
|
127
|
-
// ───
|
|
144
|
+
// ─── SpatialList — interactions ──────────────────────────────────────────────────
|
|
128
145
|
|
|
129
|
-
describe('
|
|
146
|
+
describe('SpatialList — interactions', () => {
|
|
130
147
|
it('calls onFocus when the listbox receives focus', () => {
|
|
131
148
|
const onFocus = jest.fn()
|
|
132
|
-
const { container } = render(<
|
|
149
|
+
const { container } = render(<SpatialList onFocus={onFocus} />)
|
|
133
150
|
fireEvent.focus(container.querySelector(LISTBOX)) // NOSONAR
|
|
134
151
|
expect(onFocus).toHaveBeenCalled()
|
|
135
152
|
})
|
|
136
153
|
|
|
137
154
|
it('calls onBlur when the listbox loses focus', () => {
|
|
138
155
|
const onBlur = jest.fn()
|
|
139
|
-
const { container } = render(<
|
|
156
|
+
const { container } = render(<SpatialList onBlur={onBlur} />)
|
|
140
157
|
fireEvent.blur(container.querySelector(LISTBOX)) // NOSONAR
|
|
141
158
|
expect(onBlur).toHaveBeenCalled()
|
|
142
159
|
})
|
|
143
160
|
|
|
144
161
|
it('calls onSelectItem with the clicked item id', () => {
|
|
145
162
|
const onSelectItem = jest.fn()
|
|
146
|
-
const { container } = render(<
|
|
163
|
+
const { container } = render(<SpatialList items={ITEMS} onSelectItem={onSelectItem} />)
|
|
147
164
|
fireEvent.click(container.querySelectorAll(OPTION)[1]) // NOSONAR
|
|
148
165
|
expect(onSelectItem).toHaveBeenCalledWith('f2')
|
|
149
166
|
})
|
|
150
167
|
|
|
151
168
|
it('does not throw on click when onSelectItem is not provided', () => {
|
|
152
|
-
const { container } = render(<
|
|
169
|
+
const { container } = render(<SpatialList items={ITEMS} />)
|
|
153
170
|
expect(() => fireEvent.click(container.querySelectorAll(OPTION)[0])).not.toThrow() // NOSONAR
|
|
154
171
|
})
|
|
155
172
|
})
|
|
156
173
|
|
|
157
|
-
// ───
|
|
174
|
+
// ─── SpatialList + useSpatialListFocus — roving tabindex wired together for real ────
|
|
158
175
|
//
|
|
159
|
-
// The tests above exercise
|
|
160
|
-
//
|
|
176
|
+
// The tests above exercise SpatialList in isolation (mocked onFocus/onBlur), and
|
|
177
|
+
// useSpatialListFocus.test.js exercises the hook in isolation (onFocus/onBlur called
|
|
161
178
|
// directly, not via real DOM events). Neither can catch a bug where moving real focus
|
|
162
179
|
// between sibling options — which roving tabindex does on every arrow key — fires a
|
|
163
180
|
// genuine native focusin that bubbles up and re-triggers React's onFocus on the <ul>.
|
|
@@ -165,19 +182,19 @@ describe('Features — interactions', () => {
|
|
|
165
182
|
|
|
166
183
|
const RovingHarness = ({ eventBus }) => {
|
|
167
184
|
const viewportRef = useRef(document.createElement('div'))
|
|
168
|
-
const
|
|
169
|
-
const {
|
|
170
|
-
viewportRef,
|
|
185
|
+
const spatialListRef = useRef(null)
|
|
186
|
+
const { activeItemId, tabbableId, selectedIds, onFocus, onBlur, selectItem } = useSpatialListFocus({
|
|
187
|
+
viewportRef, spatialListRef, items: ITEMS, eventBus, hints: { subscribe: () => () => {}, dismiss: () => {} }
|
|
171
188
|
})
|
|
172
189
|
return (
|
|
173
|
-
<
|
|
174
|
-
ref={
|
|
190
|
+
<SpatialList
|
|
191
|
+
ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds}
|
|
175
192
|
items={ITEMS} onFocus={onFocus} onBlur={onBlur} onSelectItem={selectItem}
|
|
176
193
|
/>
|
|
177
194
|
)
|
|
178
195
|
}
|
|
179
196
|
|
|
180
|
-
describe('
|
|
197
|
+
describe('SpatialList + useSpatialListFocus — roving tabindex real focus events', () => {
|
|
181
198
|
it('emits map:setactivefeature exactly once when arrowing to the next option', () => {
|
|
182
199
|
const eb = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
|
|
183
200
|
const { container } = render(<RovingHarness eventBus={eb} />)
|
|
@@ -185,7 +202,7 @@ describe('Features + useFeatureFocus — roving tabindex real focus events', ()
|
|
|
185
202
|
eb.emit.mockClear()
|
|
186
203
|
fireEvent.keyDown(container.querySelector(LISTBOX), { key: 'ArrowDown' }) // NOSONAR
|
|
187
204
|
expect(eb.emit).toHaveBeenCalledTimes(1)
|
|
188
|
-
expect(eb.emit).toHaveBeenCalledWith('map:
|
|
205
|
+
expect(eb.emit).toHaveBeenCalledWith('map:setactiveitem', { id: 'f2' })
|
|
189
206
|
})
|
|
190
207
|
|
|
191
208
|
it('moves real DOM focus to the next option on ArrowDown', () => {
|
|
@@ -197,7 +214,7 @@ describe('Features + useFeatureFocus — roving tabindex real focus events', ()
|
|
|
197
214
|
})
|
|
198
215
|
|
|
199
216
|
// A real pub/sub bus, with a minimal fake "interact plugin" listening on it — mirroring what
|
|
200
|
-
//
|
|
217
|
+
// useSpatialList.js actually does: track the last map:setactivefeature id, and on
|
|
201
218
|
// map:selectfeature, mark THAT id selected and fire interact:selectionchange back. This is
|
|
202
219
|
// the closest reproduction of the real reported bug: Enter only ever selecting the first item
|
|
203
220
|
// because every arrow move was clobbering the active id back to a stale value.
|
|
@@ -209,8 +226,8 @@ describe('Features + useFeatureFocus — roving tabindex real focus events', ()
|
|
|
209
226
|
off: (event, fn) => { listeners[event] = (listeners[event] ?? []).filter(f => f !== fn) },
|
|
210
227
|
emit: (event, payload) => { (listeners[event] ?? []).forEach(fn => fn(payload)) }
|
|
211
228
|
}
|
|
212
|
-
bus.on('map:
|
|
213
|
-
bus.on('map:
|
|
229
|
+
bus.on('map:setactiveitem', ({ id }) => { lastActiveId = id })
|
|
230
|
+
bus.on('map:selectitem', () => {
|
|
214
231
|
bus.emit('interact:selectionchange', { selectedMarkers: lastActiveId ? [lastActiveId] : [] })
|
|
215
232
|
})
|
|
216
233
|
return bus
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useRef, useEffect } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { useSpatialListFocus } from '../../hooks/useSpatialListFocus.js'
|
|
3
|
+
import { useSpatialListItems } from '../../hooks/useSpatialListItems.js'
|
|
4
4
|
import { EVENTS as events } from '../../../config/events.js'
|
|
5
|
+
import { scaleFactor } from '../../../config/appConfig.js'
|
|
5
6
|
import { useConfig } from '../../store/configContext.js'
|
|
6
7
|
import { useApp } from '../../store/appContext.js'
|
|
7
8
|
import { useMap } from '../../store/mapContext.js'
|
|
@@ -12,7 +13,7 @@ import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts.js'
|
|
|
12
13
|
import { useMapEvents } from '../../hooks/useMapEvents.js'
|
|
13
14
|
import { MapStatus } from './MapStatus.jsx'
|
|
14
15
|
import { CrossHair } from '../CrossHair/CrossHair'
|
|
15
|
-
import {
|
|
16
|
+
import { SpatialList } from './SpatialList'
|
|
16
17
|
import { Markers } from '../Markers/Markers'
|
|
17
18
|
|
|
18
19
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
@@ -20,14 +21,23 @@ import { Markers } from '../Markers/Markers'
|
|
|
20
21
|
export const Viewport = () => {
|
|
21
22
|
const { id, mapProvider, mapLabel, keyboardHintText, focusOnMount } = useConfig()
|
|
22
23
|
const { mode, previousMode, layoutRefs, safeZoneInset, dispatch } = useApp()
|
|
23
|
-
const { mapSize } = useMap()
|
|
24
|
+
const { mapSize, isMapReady } = useMap()
|
|
24
25
|
const { eventBus, hints } = useService()
|
|
25
26
|
|
|
26
27
|
const mapContainerRef = useRef(null)
|
|
27
|
-
const
|
|
28
|
+
const spatialListRef = useRef(null)
|
|
28
29
|
|
|
29
|
-
const { items:
|
|
30
|
-
|
|
30
|
+
const { items: spatialListItems, multiselectable, label: spatialListLabel, focusable: spatialListFocusable } = useSpatialListItems(eventBus)
|
|
31
|
+
// Same reference point as Alt+Enter's highlightLabelAtCenter — lets the listbox's entry
|
|
32
|
+
// fallback prefer whichever item is nearest the middle of the view. Guarded on isMapReady:
|
|
33
|
+
// the map engine doesn't exist yet on Viewport's first render, so mapToScreen()/getCenter()
|
|
34
|
+
// would throw; null is a safe, already-handled value (falls back to the first item).
|
|
35
|
+
let centerScreenPoint = null
|
|
36
|
+
if (isMapReady) {
|
|
37
|
+
const centerPixel = mapProvider.mapToScreen(mapProvider.getCenter())
|
|
38
|
+
centerScreenPoint = { x: centerPixel.x * scaleFactor[mapSize], y: centerPixel.y * scaleFactor[mapSize] }
|
|
39
|
+
}
|
|
40
|
+
const { activeItemId, tabbableId, selectedIds, onFocus: handleSpatialListFocus, onBlur: handleSpatialListBlur, selectItem } = useSpatialListFocus({ viewportRef: layoutRefs.viewportRef, spatialListRef, items: spatialListItems, eventBus, hints, centerScreenPoint })
|
|
31
41
|
|
|
32
42
|
useEffect(() => {
|
|
33
43
|
const handler = () => dispatch({ type: 'SET_LISTBOX_ACTIVE' })
|
|
@@ -35,8 +45,8 @@ export const Viewport = () => {
|
|
|
35
45
|
return () => eventBus.off('interact:listboxcapable', handler)
|
|
36
46
|
}, [eventBus])
|
|
37
47
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
48
|
+
const onSpatialListFocus = () => { handleSpatialListFocus(); hints.show(keyboardHintText, { duration: 0 }) }
|
|
49
|
+
const onSpatialListBlur = () => { handleSpatialListBlur(); hints.dismiss() }
|
|
40
50
|
|
|
41
51
|
useKeyboardShortcuts(layoutRefs.viewportRef)
|
|
42
52
|
|
|
@@ -94,7 +104,7 @@ export const Viewport = () => {
|
|
|
94
104
|
<Markers />
|
|
95
105
|
</div>
|
|
96
106
|
</div>
|
|
97
|
-
<
|
|
107
|
+
<SpatialList ref={spatialListRef} activeItemId={activeItemId} tabbableId={tabbableId} selectedIds={selectedIds} multiselectable={multiselectable} items={spatialListItems} label={spatialListLabel} focusable={spatialListFocusable} onFocus={onSpatialListFocus} onBlur={onSpatialListBlur} onSelectItem={selectItem} />
|
|
98
108
|
</>
|
|
99
109
|
)
|
|
100
110
|
}
|
|
@@ -21,7 +21,13 @@ jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid
|
|
|
21
21
|
|
|
22
22
|
const KEYBOARD_HINT_TEXT = 'Test keyboad hint text'
|
|
23
23
|
|
|
24
|
-
const mockMapProvider = {
|
|
24
|
+
const mockMapProvider = {
|
|
25
|
+
initMap: jest.fn(),
|
|
26
|
+
updateMap: jest.fn(),
|
|
27
|
+
clearHighlightedLabel: jest.fn(),
|
|
28
|
+
getCenter: jest.fn(() => [0, 0]),
|
|
29
|
+
mapToScreen: jest.fn(() => ({ x: 100, y: 100 }))
|
|
30
|
+
}
|
|
25
31
|
|
|
26
32
|
function setupHookMocks (mainEl, viewportEl) {
|
|
27
33
|
useConfig.mockReturnValue({
|
|
@@ -38,7 +44,7 @@ function setupHookMocks (mainEl, viewportEl) {
|
|
|
38
44
|
safeZoneInset: {},
|
|
39
45
|
dispatch: jest.fn()
|
|
40
46
|
})
|
|
41
|
-
useMap.mockReturnValue({ mapSize: 'medium', dispatch: jest.fn() })
|
|
47
|
+
useMap.mockReturnValue({ mapSize: 'medium', isMapReady: true, dispatch: jest.fn() })
|
|
42
48
|
useService.mockReturnValue({
|
|
43
49
|
announce: jest.fn(),
|
|
44
50
|
hints: { show: jest.fn(), dismiss: jest.fn(), subscribe: jest.fn(() => jest.fn()) },
|
|
@@ -89,6 +95,16 @@ describe('Viewport rendering', () => {
|
|
|
89
95
|
expect(markers).toBeInTheDocument()
|
|
90
96
|
})
|
|
91
97
|
|
|
98
|
+
// Regression: Viewport (and MapController, which kicks off map creation) render before the
|
|
99
|
+
// underlying map engine actually exists — mapProvider.getCenter()/mapToScreen() throw until
|
|
100
|
+
// then, so the spatial listbox's "nearest to center" entry point must not call them yet.
|
|
101
|
+
it('does not throw, and skips mapProvider.getCenter/mapToScreen, before the map is ready', () => {
|
|
102
|
+
useMap.mockReturnValue({ mapSize: 'medium', isMapReady: false, dispatch: jest.fn() })
|
|
103
|
+
expect(() => renderViewport()).not.toThrow()
|
|
104
|
+
expect(mockMapProvider.getCenter).not.toHaveBeenCalled()
|
|
105
|
+
expect(mockMapProvider.mapToScreen).not.toHaveBeenCalled()
|
|
106
|
+
})
|
|
107
|
+
|
|
92
108
|
it('renders viewport with correct id and class based on mapSize', () => {
|
|
93
109
|
const { viewport } = renderViewport()
|
|
94
110
|
expect(viewport.id).toBe('test-map-viewport')
|
|
@@ -36,13 +36,13 @@ const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition
|
|
|
36
36
|
// tree while invisible. An earlier version kept it ax-tree-present here so Voice Control
|
|
37
37
|
// could re-summon it by name, but that made "Show Names" put a "Target" label over
|
|
38
38
|
// nothing whenever it was genuinely hidden, which is more confusing than helpful. Voice
|
|
39
|
-
// Control's real route back is opening
|
|
40
|
-
// button) — see
|
|
41
|
-
// discoverable while it's not actually there. Blocked outright while
|
|
39
|
+
// Control's real route back is opening MapControls (a normal, always-visible, always-named
|
|
40
|
+
// button) — see isMapControlsOpen below — so the crosshair itself doesn't need to stay
|
|
41
|
+
// discoverable while it's not actually there. Blocked outright while MapControls is open:
|
|
42
42
|
// that panel is a deliberate, explicit "I can't drag/pinch this map" signal from the user,
|
|
43
43
|
// which should win over whatever mouse-vs-touch/keyboard inference would otherwise decide.
|
|
44
44
|
crossHair.hide = () => {
|
|
45
|
-
if (crossHair.
|
|
45
|
+
if (crossHair.isMapControlsOpen) {
|
|
46
46
|
return
|
|
47
47
|
}
|
|
48
48
|
el.style.display = 'none'
|
|
@@ -124,7 +124,7 @@ export const useCrossHair = () => {
|
|
|
124
124
|
// read the stale value and incorrectly block itself. Setting it directly in the render body
|
|
125
125
|
// (matches DrawInit.jsx's shouldShowCrosshairRef convention) guarantees it's current before
|
|
126
126
|
// ANY component's effects run, since React finishes rendering the whole tree first.
|
|
127
|
-
crossHair.
|
|
127
|
+
crossHair.isMapControlsOpen = expandedButtons?.has('mapControls')
|
|
128
128
|
|
|
129
129
|
return {
|
|
130
130
|
crossHair,
|
|
@@ -98,20 +98,20 @@ describe('useCrossHair', () => {
|
|
|
98
98
|
expect(mockElement.style.display).toBe('none')
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
-
it('hide() is a no-op while
|
|
101
|
+
it('hide() is a no-op while isMapControlsOpen is set', () => {
|
|
102
102
|
setup()
|
|
103
103
|
act(() => mockCrossHair.show())
|
|
104
|
-
mockCrossHair.
|
|
104
|
+
mockCrossHair.isMapControlsOpen = true
|
|
105
105
|
|
|
106
106
|
act(() => mockCrossHair.hide())
|
|
107
107
|
expect(mockElement.style.display).toBe('block')
|
|
108
108
|
expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } })
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
-
it('keeps crossHair.
|
|
112
|
-
useApp.mockReturnValue({ safeZoneInset: { left: 10, top: 20 }, expandedButtons: new Set(['
|
|
111
|
+
it('keeps crossHair.isMapControlsOpen 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(['mapControls']) })
|
|
113
113
|
setup()
|
|
114
|
-
expect(mockCrossHair.
|
|
114
|
+
expect(mockCrossHair.isMapControlsOpen).toBe(true)
|
|
115
115
|
})
|
|
116
116
|
|
|
117
117
|
it('setStyle updates state', () => {
|
|
@@ -32,9 +32,9 @@ export const useHintsAPI = () => {
|
|
|
32
32
|
}, [eventBus, hints])
|
|
33
33
|
|
|
34
34
|
// Escape dismisses the active hint when the keypress originates inside this
|
|
35
|
-
// map instance. The viewport and
|
|
35
|
+
// map instance. The viewport and spatial listbox already dismiss hints on
|
|
36
36
|
// Escape within their own narrower focus scope (useKeyboardHint.js /
|
|
37
|
-
//
|
|
37
|
+
// useSpatialListFocus.js) — this covers hints shown via the public showHint()
|
|
38
38
|
// API from anywhere else in the same map's UI (e.g. a plugin button).
|
|
39
39
|
//
|
|
40
40
|
// Listening on document (rather than the map's own container) and checking
|
|
@@ -51,12 +51,12 @@ export const useHintsAPI = () => {
|
|
|
51
51
|
}, [hints])
|
|
52
52
|
|
|
53
53
|
useEffect(() => {
|
|
54
|
-
const handleKeyDown = (
|
|
55
|
-
if (
|
|
54
|
+
const handleKeyDown = (event) => {
|
|
55
|
+
if (event.key !== 'Escape' || !isHintActiveRef.current) {
|
|
56
56
|
return
|
|
57
57
|
}
|
|
58
58
|
const container = layoutRefs.appContainerRef?.current
|
|
59
|
-
if (container && !container.contains(
|
|
59
|
+
if (container && !container.contains(event.target)) {
|
|
60
60
|
return
|
|
61
61
|
}
|
|
62
62
|
hints.dismiss()
|