@defra/interactive-map 0.0.8-alpha → 0.0.10-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 (104) hide show
  1. package/DOCS_README.md +39 -0
  2. package/dist/esm/im-core.js +1 -0
  3. package/dist/esm/im-shell.js +1 -0
  4. package/dist/esm/index.js +1 -2
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +104 -3
  8. package/docs/api.md +22 -2
  9. package/docs/architecture/architecture-diagrams.md +1 -3
  10. package/docs/architecture/diagrams-viewer.mdx +12 -0
  11. package/docs/getting-started.md +78 -8
  12. package/docs/govuk-prototype.md +23 -0
  13. package/docs/index.md +23 -0
  14. package/docusaurus.config.cjs +106 -0
  15. package/mise.toml +2 -0
  16. package/package.json +51 -27
  17. package/plugins/beta/datasets/dist/css/index.css +50 -1
  18. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -0
  19. package/plugins/beta/datasets/dist/esm/index.js +1 -2
  20. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -0
  21. package/plugins/beta/draw-es/dist/esm/index.js +1 -2
  22. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
  23. package/plugins/beta/draw-es/src/events.js +2 -2
  24. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -0
  25. package/plugins/beta/draw-ml/dist/esm/index.js +1 -2
  26. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  27. package/plugins/beta/draw-ml/src/api/newLine.js +2 -2
  28. package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
  29. package/plugins/beta/draw-ml/src/events.js +18 -10
  30. package/plugins/beta/frame/dist/css/index.css +11 -1
  31. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -0
  32. package/plugins/beta/frame/dist/esm/index.js +1 -2
  33. package/plugins/beta/map-styles/dist/css/index.css +79 -1
  34. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -0
  35. package/plugins/beta/map-styles/dist/esm/index.js +1 -2
  36. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -0
  37. package/plugins/beta/scale-bar/dist/esm/index.js +1 -2
  38. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -0
  39. package/plugins/beta/use-location/dist/esm/index.js +1 -2
  40. package/plugins/beta/use-location/dist/umd/index.js +1 -1
  41. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -0
  42. package/plugins/interact/dist/esm/index.js +1 -2
  43. package/plugins/search/dist/esm/im-search-plugin.js +1 -0
  44. package/plugins/search/dist/esm/index.js +1 -2
  45. package/plugins/search/src/Search.test.jsx +170 -0
  46. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
  47. package/plugins/search/src/components/Form/Form.test.jsx +158 -0
  48. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
  49. package/plugins/search/src/components/Suggestions/Suggestions.module.scss +1 -1
  50. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
  51. package/plugins/search/src/datasets.test.js +46 -0
  52. package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
  53. package/plugins/search/src/events/formHandlers.test.js +232 -0
  54. package/plugins/search/src/events/index.test.js +118 -0
  55. package/plugins/search/src/events/inputHandlers.test.js +104 -0
  56. package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
  57. package/plugins/search/src/index.test.js +47 -0
  58. package/plugins/search/src/reducer.test.js +80 -0
  59. package/plugins/search/src/search.scss +1 -1
  60. package/plugins/search/src/utils/parseOsNamesResults.js +2 -1
  61. package/plugins/search/src/utils/parseOsNamesResults.test.js +140 -0
  62. package/plugins/search/src/utils/updateMap.test.js +52 -0
  63. package/providers/beta/esri/dist/css/index.css +30 -1
  64. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -0
  65. package/providers/beta/esri/dist/esm/index.js +1 -2
  66. package/providers/beta/open-names/dist/esm/im-reverse-geocode.js +1 -0
  67. package/providers/beta/open-names/dist/esm/index.js +1 -2
  68. package/providers/beta/open-names/src/utils/mapToLocationModel.test.js +61 -0
  69. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -0
  70. package/providers/maplibre/dist/esm/index.js +1 -2
  71. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  72. package/providers/maplibre/src/appEvents.test.js +44 -0
  73. package/providers/maplibre/src/index.test.js +60 -0
  74. package/providers/maplibre/src/mapEvents.test.js +115 -0
  75. package/providers/maplibre/src/maplibreProvider.test.js +205 -0
  76. package/providers/maplibre/src/utils/calculateLinearTextSize.test.js +31 -0
  77. package/providers/maplibre/src/utils/detectWebgl.test.js +63 -0
  78. package/providers/maplibre/src/utils/highlightFeatures.test.js +126 -0
  79. package/providers/maplibre/src/utils/labels.js +1 -3
  80. package/providers/maplibre/src/utils/labels.test.js +231 -0
  81. package/providers/maplibre/src/utils/maplibreFixes.test.js +66 -0
  82. package/providers/maplibre/src/utils/queryFeatures.test.js +60 -0
  83. package/providers/maplibre/src/utils/spatial.js +5 -4
  84. package/providers/maplibre/src/utils/spatial.test.js +96 -0
  85. package/rollup.esm.mjs +288 -0
  86. package/src/App/store/appActionsMap.js +1 -1
  87. package/src/InteractiveMap/InteractiveMap.js +3 -2
  88. package/webpack.dev.mjs +9 -1
  89. package/webpack.prod.mjs +8 -1
  90. package/webpack.umd.mjs +1 -2
  91. package/dist/esm/index.js.LICENSE.txt +0 -1
  92. package/plugins/beta/datasets/dist/esm/index.js.LICENSE.txt +0 -1
  93. package/plugins/beta/draw-es/dist/esm/index.js.LICENSE.txt +0 -1
  94. package/plugins/beta/draw-ml/dist/esm/index.js.LICENSE.txt +0 -1
  95. package/plugins/beta/frame/dist/esm/index.js.LICENSE.txt +0 -1
  96. package/plugins/beta/map-styles/dist/esm/index.js.LICENSE.txt +0 -1
  97. package/plugins/beta/scale-bar/dist/esm/index.js.LICENSE.txt +0 -1
  98. package/plugins/beta/use-location/dist/esm/index.js.LICENSE.txt +0 -1
  99. package/plugins/interact/dist/esm/index.js.LICENSE.txt +0 -1
  100. package/plugins/search/dist/esm/index.js.LICENSE.txt +0 -1
  101. package/providers/beta/esri/dist/esm/index.js.LICENSE.txt +0 -1
  102. package/providers/beta/open-names/dist/esm/index.js.LICENSE.txt +0 -1
  103. package/providers/maplibre/dist/esm/index.js.LICENSE.txt +0 -6
  104. package/webpack.esm.mjs +0 -154
@@ -0,0 +1,47 @@
1
+ // src/plugins/search/OpenButton.test.jsx
2
+
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { OpenButton } from './OpenButton'
5
+
6
+ describe('OpenButton', () => {
7
+ const baseProps = {
8
+ id: 'test',
9
+ isExpanded: false,
10
+ onClick: jest.fn(),
11
+ buttonRef: { current: null },
12
+ searchIcon: null,
13
+ }
14
+
15
+ beforeEach(() => {
16
+ jest.clearAllMocks()
17
+ })
18
+
19
+ it('renders the button with correct ARIA attributes and calls onClick', () => {
20
+ render(<OpenButton {...baseProps} />)
21
+ const button = screen.getByRole('button', { name: /open search/i })
22
+ expect(button).toHaveAttribute('aria-controls', 'test-search-form')
23
+ fireEvent.click(button)
24
+ expect(baseProps.onClick).toHaveBeenCalledTimes(1)
25
+ })
26
+
27
+ it('applies display:none when isExpanded is true', () => {
28
+ render(<OpenButton {...baseProps} isExpanded />)
29
+ const button = screen.getByLabelText('Open search')
30
+ expect(button).toHaveStyle({ display: 'none' })
31
+ })
32
+
33
+ it('renders the search icon SVG when searchIcon is provided', () => {
34
+ const svgContent = '<path d="M1 1L23 23" />'
35
+ const { container } = render(
36
+ <OpenButton {...baseProps} searchIcon={svgContent} />
37
+ )
38
+ const svg = container.querySelector('svg')
39
+ expect(svg).toBeTruthy()
40
+ expect(svg.innerHTML).toContain('M1 1L23 23')
41
+ })
42
+
43
+ it('does not render an SVG when searchIcon is not provided', () => {
44
+ const { container } = render(<OpenButton {...baseProps} />)
45
+ expect(container.querySelector('svg')).toBeNull()
46
+ })
47
+ })
@@ -1,4 +1,4 @@
1
- @use '/src/scss/tools/index' as tools;
1
+ @use '../../../../../src/scss/tools/index' as tools;
2
2
 
3
3
  // Suggestions
4
4
  .im-c-search-suggestions {
@@ -0,0 +1,79 @@
1
+ // src/plugins/search/Suggestions.test.jsx
2
+
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { Suggestions } from './Suggestions'
5
+
6
+ describe('Suggestions', () => {
7
+ const baseProps = {
8
+ id: 'test',
9
+ pluginState: {
10
+ areSuggestionsVisible: true,
11
+ suggestions: [
12
+ { id: '1', marked: 'First' },
13
+ { id: '2', marked: 'Second' },
14
+ ],
15
+ selectedIndex: 0,
16
+ },
17
+ handleSuggestionClick: jest.fn(),
18
+ }
19
+
20
+ beforeEach(() => {
21
+ jest.clearAllMocks()
22
+ })
23
+
24
+ it('renders the listbox with correct attributes', () => {
25
+ render(<Suggestions {...baseProps} />)
26
+ const listbox = screen.getByRole('listbox')
27
+ expect(listbox).toHaveAttribute('id', 'test-search-suggestions')
28
+ expect(listbox).toHaveAttribute('aria-labelledby', 'test-search')
29
+ expect(listbox.className).toContain('im-c-search-suggestions')
30
+ })
31
+
32
+ it('hides the listbox when suggestions are not visible', () => {
33
+ render(
34
+ <Suggestions
35
+ {...baseProps}
36
+ pluginState={{ ...baseProps.pluginState, areSuggestionsVisible: false }}
37
+ />
38
+ )
39
+ const listbox = screen.getByRole('listbox', { hidden: true })
40
+ expect(listbox).toHaveStyle({ display: 'none' })
41
+ })
42
+
43
+ it('hides the listbox when suggestions array is empty', () => {
44
+ render(
45
+ <Suggestions
46
+ {...baseProps}
47
+ pluginState={{ ...baseProps.pluginState, suggestions: [] }}
48
+ />
49
+ )
50
+ const listbox = screen.getByRole('listbox', { hidden: true })
51
+ expect(listbox).toHaveStyle({ display: 'none' })
52
+ })
53
+
54
+ it('renders all suggestion items with correct ARIA attributes', () => {
55
+ render(<Suggestions {...baseProps} />)
56
+ const items = screen.getAllByRole('option')
57
+ expect(items).toHaveLength(2)
58
+
59
+ items.forEach((item, i) => {
60
+ expect(item).toHaveAttribute('id', `test-search-suggestion-${i}`)
61
+ expect(item).toHaveClass('im-c-search-suggestions__item')
62
+ expect(item).toHaveAttribute('aria-setsize', '2')
63
+ expect(item).toHaveAttribute('aria-posinset', `${i + 1}`)
64
+ })
65
+
66
+ // First item should be selected
67
+ expect(items[0]).toHaveAttribute('aria-selected', 'true')
68
+ expect(items[1]).toHaveAttribute('aria-selected', 'false')
69
+ })
70
+
71
+ it('calls handleSuggestionClick when a suggestion is clicked', () => {
72
+ render(<Suggestions {...baseProps} />)
73
+ const items = screen.getAllByRole('option')
74
+ fireEvent.click(items[1])
75
+ expect(baseProps.handleSuggestionClick).toHaveBeenCalledWith(
76
+ baseProps.pluginState.suggestions[1]
77
+ )
78
+ })
79
+ })
@@ -0,0 +1,46 @@
1
+ // src/plugins/search/datasets.test.js
2
+
3
+ import { createDatasets } from './datasets'
4
+ import * as parseModule from './utils/parseOsNamesResults.js'
5
+
6
+ describe('createDatasets', () => {
7
+ const osNamesURL = 'https://example.com/osnames'
8
+ const crs = 'EPSG:4326'
9
+
10
+ beforeEach(() => {
11
+ jest.clearAllMocks()
12
+ })
13
+
14
+ it('returns default dataset with correct properties', () => {
15
+ const datasets = createDatasets({ osNamesURL, crs })
16
+ expect(datasets).toHaveLength(1)
17
+ const ds = datasets[0]
18
+
19
+ expect(ds.name).toBe('osNames')
20
+ expect(ds.urlTemplate).toBe(osNamesURL)
21
+ expect(ds.includeRegex).toEqual(/^[a-zA-Z0-9\s,-]+$/)
22
+ expect(ds.excludeRegex).toEqual(
23
+ /^(?:[a-z]{2}\s*(?:\d{3}\s*\d{3}|\d{4}\s*\d{4}|\d{5}\s*\d{5})|\d+\s*,?\s*\d+)$/i
24
+ )
25
+ expect(typeof ds.parseResults).toBe('function')
26
+ })
27
+
28
+ it('merge custom datasets with default dataset', () => {
29
+ const custom = [{ name: 'custom1', urlTemplate: 'https://custom.com' }]
30
+ const datasets = createDatasets({ osNamesURL, crs, customDatasets: custom })
31
+ expect(datasets).toHaveLength(2)
32
+ expect(datasets[1]).toEqual(custom[0])
33
+ })
34
+
35
+ it('parseResults calls parseOsNamesResults with correct arguments', () => {
36
+ const parseMock = jest.spyOn(parseModule, 'parseOsNamesResults').mockReturnValue('parsed')
37
+ const datasets = createDatasets({ osNamesURL, crs })
38
+ const json = { some: 'data' }
39
+ const query = 'test query'
40
+
41
+ const result = datasets[0].parseResults(json, query)
42
+ expect(parseMock).toHaveBeenCalledWith(json, query, crs)
43
+ expect(result).toBe('parsed')
44
+ parseMock.mockRestore()
45
+ })
46
+ })
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { fetchSuggestions, sanitiseQuery } from './fetchSuggestions.js'
5
+
6
+ describe('fetchSuggestions', () => {
7
+ const dispatch = jest.fn()
8
+
9
+ beforeEach(() => {
10
+ dispatch.mockClear()
11
+ global.fetch = jest.fn()
12
+ jest.spyOn(console, 'error').mockImplementation(() => {})
13
+ })
14
+
15
+ afterEach(() => {
16
+ jest.restoreAllMocks()
17
+ })
18
+
19
+ test('sanitiseQuery strips invalid chars and trims', () => {
20
+ expect(sanitiseQuery(' he!!llo@ ')).toBe('hello')
21
+ })
22
+
23
+ test('fetches results, applies parsing, and dispatches', async () => {
24
+ global.fetch.mockResolvedValueOnce({
25
+ ok: true,
26
+ json: async () => ({ items: ['a', 'b'] })
27
+ })
28
+
29
+ const datasets = [
30
+ {
31
+ urlTemplate: '/api?q={query}',
32
+ parseResults: (json) => json.items
33
+ }
34
+ ]
35
+
36
+ const result = await fetchSuggestions('test', datasets, dispatch)
37
+
38
+ expect(fetch).toHaveBeenCalledWith('/api?q=test', { method: 'GET' })
39
+ expect(result.results).toEqual(['a', 'b'])
40
+ expect(dispatch).toHaveBeenCalledWith({
41
+ type: 'UPDATE_SUGGESTIONS',
42
+ payload: ['a', 'b']
43
+ })
44
+ })
45
+
46
+ test('respects includeRegex and excludeRegex', async () => {
47
+ const datasets = [
48
+ {
49
+ includeRegex: /^ok/,
50
+ excludeRegex: /bad/,
51
+ urlTemplate: '/x?q={query}',
52
+ parseResults: () => ['x']
53
+ }
54
+ ]
55
+
56
+ const result = await fetchSuggestions('bad', datasets, dispatch)
57
+
58
+ expect(fetch).not.toHaveBeenCalled()
59
+ expect(result.results).toEqual([])
60
+ })
61
+
62
+ test('uses buildRequest when provided', async () => {
63
+ global.fetch.mockResolvedValueOnce({
64
+ ok: true,
65
+ json: async () => ({})
66
+ })
67
+
68
+ const datasets = [
69
+ {
70
+ buildRequest: (query) => ({
71
+ url: `/custom/${query}`,
72
+ options: { method: 'POST' }
73
+ }),
74
+ parseResults: () => ['y']
75
+ }
76
+ ]
77
+
78
+ const result = await fetchSuggestions('abc', datasets, dispatch)
79
+
80
+ expect(fetch).toHaveBeenCalledWith('/custom/abc', { method: 'POST' })
81
+ expect(result.results).toEqual(['y'])
82
+ })
83
+
84
+ test('uses transformRequest when provided', async () => {
85
+ global.fetch.mockResolvedValueOnce({
86
+ ok: true,
87
+ json: async () => ({})
88
+ })
89
+
90
+ const transformRequest = (req) => ({
91
+ ...req,
92
+ options: { method: 'PUT' }
93
+ })
94
+
95
+ const datasets = [
96
+ {
97
+ urlTemplate: '/t?q={query}',
98
+ parseResults: () => ['z']
99
+ }
100
+ ]
101
+
102
+ await fetchSuggestions('x', datasets, dispatch, transformRequest)
103
+
104
+ expect(fetch).toHaveBeenCalledWith('/t?q=x', { method: 'PUT' })
105
+ })
106
+
107
+ test('handles fetch HTTP error', async () => {
108
+ global.fetch.mockResolvedValueOnce({ ok: false, status: 500 })
109
+
110
+ const datasets = [
111
+ {
112
+ label: 'test-ds',
113
+ urlTemplate: '/fail?q={query}',
114
+ parseResults: () => ['nope']
115
+ }
116
+ ]
117
+
118
+ const result = await fetchSuggestions('err', datasets, dispatch)
119
+
120
+ expect(result.results).toEqual([])
121
+ expect(console.error).toHaveBeenCalled()
122
+ })
123
+
124
+ test('uses fallback dataset label on fetch HTTP error', async () => {
125
+ global.fetch.mockResolvedValueOnce({
126
+ ok: false,
127
+ status: 404
128
+ })
129
+
130
+ const datasets = [
131
+ {
132
+ // no label on purpose
133
+ urlTemplate: '/missing?q={query}',
134
+ parseResults: () => []
135
+ }
136
+ ]
137
+
138
+ await fetchSuggestions('oops', datasets, dispatch)
139
+
140
+ expect(console.error).toHaveBeenCalledWith(
141
+ 'Fetch error for dataset: 404'
142
+ )
143
+ })
144
+
145
+ test('handles network error', async () => {
146
+ global.fetch.mockRejectedValueOnce(new Error('network'))
147
+
148
+ const datasets = [
149
+ {
150
+ urlTemplate: '/net?q={query}',
151
+ parseResults: () => ['nope']
152
+ }
153
+ ]
154
+
155
+ const result = await fetchSuggestions('err', datasets, dispatch)
156
+
157
+ expect(result.results).toEqual([])
158
+ expect(console.error).toHaveBeenCalled()
159
+ })
160
+
161
+ test('stops processing when exclusive dataset returns results', async () => {
162
+ global.fetch
163
+ .mockResolvedValueOnce({
164
+ ok: true,
165
+ json: async () => ({})
166
+ })
167
+ .mockResolvedValueOnce({
168
+ ok: true,
169
+ json: async () => ({})
170
+ })
171
+
172
+ const datasets = [
173
+ {
174
+ exclusive: true,
175
+ urlTemplate: '/first?q={query}',
176
+ parseResults: () => ['first']
177
+ },
178
+ {
179
+ urlTemplate: '/second?q={query}',
180
+ parseResults: () => ['second']
181
+ }
182
+ ]
183
+
184
+ const result = await fetchSuggestions('go', datasets, dispatch)
185
+
186
+ expect(result.results).toEqual(['first'])
187
+ expect(fetch).toHaveBeenCalledTimes(1)
188
+ })
189
+
190
+ test('buildRequest can call default request builder', async () => {
191
+ global.fetch.mockResolvedValueOnce({
192
+ ok: true,
193
+ json: async () => ({})
194
+ })
195
+
196
+ const datasets = [
197
+ {
198
+ buildRequest: (query, getDefault) => {
199
+ // 👇 THIS is the missing function call
200
+ return getDefault()
201
+ },
202
+ urlTemplate: '/default?q={query}',
203
+ parseResults: () => ['ok']
204
+ }
205
+ ]
206
+
207
+ const result = await fetchSuggestions('hi', datasets, dispatch)
208
+
209
+ expect(fetch).toHaveBeenCalledWith('/default?q=hi', { method: 'GET' })
210
+ expect(result.results).toEqual(['ok'])
211
+ })
212
+ })
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { createFormHandlers } from './formHandlers.js'
5
+ import { fetchSuggestions } from './fetchSuggestions.js'
6
+ import { updateMap } from '../utils/updateMap.js'
7
+ import { DEFAULTS } from '../defaults.js'
8
+
9
+ jest.mock('./fetchSuggestions.js')
10
+ jest.mock('../utils/updateMap.js')
11
+
12
+ describe('createFormHandlers', () => {
13
+ let dispatch
14
+ let services
15
+ let viewportRef
16
+ let markers
17
+ let handlers
18
+
19
+ beforeEach(() => {
20
+ dispatch = jest.fn()
21
+
22
+ services = {
23
+ eventBus: { emit: jest.fn() }
24
+ }
25
+
26
+ viewportRef = {
27
+ current: { focus: jest.fn() }
28
+ }
29
+
30
+ markers = {
31
+ remove: jest.fn()
32
+ }
33
+
34
+ handlers = createFormHandlers({
35
+ dispatch,
36
+ services,
37
+ viewportRef,
38
+ mapProvider: 'map',
39
+ markers,
40
+ datasets: [],
41
+ transformRequest: jest.fn(),
42
+ showMarker: true,
43
+ markerColor: 'red'
44
+ })
45
+
46
+ jest.clearAllMocks()
47
+ })
48
+
49
+ test('handleOpenClick dispatches and emits', () => {
50
+ handlers.handleOpenClick()
51
+
52
+ expect(dispatch).toHaveBeenCalledWith({
53
+ type: 'TOGGLE_EXPANDED',
54
+ payload: true
55
+ })
56
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:open')
57
+ })
58
+
59
+ test('handleCloseClick resets state and focuses button', () => {
60
+ jest.useFakeTimers()
61
+ const buttonRef = { current: { focus: jest.fn() } }
62
+
63
+ handlers.handleCloseClick(null, buttonRef)
64
+
65
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_EXPANDED', payload: false })
66
+ expect(dispatch).toHaveBeenCalledWith({ type: 'UPDATE_SUGGESTIONS', payload: [] })
67
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_VALUE', payload: '' })
68
+ expect(markers.remove).toHaveBeenCalledWith('search')
69
+
70
+ jest.runAllTimers()
71
+ expect(buttonRef.current.focus).toHaveBeenCalled()
72
+
73
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:clear')
74
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:close')
75
+
76
+ jest.useRealTimers()
77
+ })
78
+
79
+ test('handleSubmit uses selected suggestion when selectedIndex >= 0', async () => {
80
+ const suggestion = {
81
+ text: 'Paris',
82
+ bounds: 'b',
83
+ point: 'p'
84
+ }
85
+
86
+ await handlers.handleSubmit(
87
+ { preventDefault: jest.fn() },
88
+ {},
89
+ {
90
+ suggestions: [suggestion],
91
+ selectedIndex: 0,
92
+ value: 'x'
93
+ }
94
+ )
95
+
96
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_SELECTED', payload: -1 })
97
+ expect(dispatch).toHaveBeenCalledWith({ type: 'HIDE_SUGGESTIONS' })
98
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_VALUE', payload: 'Paris' })
99
+
100
+ expect(updateMap).toHaveBeenCalledWith(
101
+ expect.objectContaining({ bounds: 'b', point: 'p' })
102
+ )
103
+
104
+ expect(services.eventBus.emit).toHaveBeenCalledWith(
105
+ 'search:match',
106
+ expect.objectContaining({ query: 'Paris' })
107
+ )
108
+ })
109
+
110
+ test('handleSubmit returns early for short input', async () => {
111
+ await handlers.handleSubmit(
112
+ { preventDefault: jest.fn() },
113
+ {},
114
+ {
115
+ suggestions: [],
116
+ selectedIndex: -1,
117
+ value: 'a'.repeat(DEFAULTS.minSearchLength - 1)
118
+ }
119
+ )
120
+
121
+ expect(fetchSuggestions).not.toHaveBeenCalled()
122
+ expect(updateMap).not.toHaveBeenCalled()
123
+ })
124
+
125
+ test('handleSubmit fetches suggestions and updates map (keyboard)', async () => {
126
+ fetchSuggestions.mockResolvedValueOnce({
127
+ sanitisedValue: 'rome',
128
+ results: [
129
+ { text: 'Rome', bounds: 'b', point: 'p' }
130
+ ]
131
+ })
132
+
133
+ await handlers.handleSubmit(
134
+ { preventDefault: jest.fn() },
135
+ { interfaceType: 'keyboard' },
136
+ {
137
+ suggestions: [],
138
+ selectedIndex: -1,
139
+ value: 'rome'
140
+ }
141
+ )
142
+
143
+ expect(fetchSuggestions).toHaveBeenCalled()
144
+ expect(viewportRef.current.focus).toHaveBeenCalled()
145
+ expect(updateMap).toHaveBeenCalled()
146
+ expect(services.eventBus.emit).toHaveBeenCalledWith(
147
+ 'search:match',
148
+ expect.objectContaining({ query: 'rome' })
149
+ )
150
+ })
151
+
152
+ test('handleSubmit mobile closes search', async () => {
153
+ fetchSuggestions.mockResolvedValueOnce({
154
+ sanitisedValue: 'berlin',
155
+ results: [
156
+ { text: 'Berlin', bounds: 'b', point: 'p' }
157
+ ]
158
+ })
159
+
160
+ await handlers.handleSubmit(
161
+ { preventDefault: jest.fn() },
162
+ { breakpoint: 'mobile' },
163
+ {
164
+ suggestions: [],
165
+ selectedIndex: -1,
166
+ value: 'berlin'
167
+ }
168
+ )
169
+
170
+ expect(dispatch).toHaveBeenCalledWith({
171
+ type: 'TOGGLE_EXPANDED',
172
+ payload: false
173
+ })
174
+ expect(services.eventBus.emit).toHaveBeenCalledWith('search:close')
175
+ })
176
+
177
+ test('handleSubmit does nothing when no suggestions are returned', async () => {
178
+ fetchSuggestions.mockResolvedValueOnce({
179
+ sanitisedValue: 'none',
180
+ results: []
181
+ })
182
+
183
+ await handlers.handleSubmit(
184
+ { preventDefault: jest.fn() },
185
+ {},
186
+ {
187
+ suggestions: [],
188
+ selectedIndex: -1,
189
+ value: 'none'
190
+ }
191
+ )
192
+
193
+ // Fetch happens
194
+ expect(fetchSuggestions).toHaveBeenCalled()
195
+
196
+ // But nothing downstream runs
197
+ expect(updateMap).not.toHaveBeenCalled()
198
+ expect(services.eventBus.emit).not.toHaveBeenCalledWith(
199
+ 'search:match',
200
+ expect.anything()
201
+ )
202
+ })
203
+
204
+ test('does not refetch when value matches lastFetchedValue', async () => {
205
+ fetchSuggestions.mockResolvedValueOnce({
206
+ sanitisedValue: 'same',
207
+ results: [{ text: 'Same', bounds: 'b', point: 'p' }]
208
+ })
209
+
210
+ await handlers.handleSubmit(
211
+ { preventDefault: jest.fn() },
212
+ {},
213
+ {
214
+ suggestions: [],
215
+ selectedIndex: -1,
216
+ value: 'same'
217
+ }
218
+ )
219
+
220
+ await handlers.handleSubmit(
221
+ { preventDefault: jest.fn() },
222
+ {},
223
+ {
224
+ suggestions: [{ text: 'Same', bounds: 'b', point: 'p' }],
225
+ selectedIndex: -1,
226
+ value: 'same'
227
+ }
228
+ )
229
+
230
+ expect(fetchSuggestions).toHaveBeenCalledTimes(1)
231
+ })
232
+ })