@defra/interactive-map 0.0.10-alpha → 0.0.12-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 (121) hide show
  1. package/README.md +1 -1
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/button-definition.md +21 -3
  8. package/docs/api/panel-definition.md +10 -12
  9. package/docs/api.md +80 -7
  10. package/docs/demo.mdx +70 -0
  11. package/docs/index.md +0 -4
  12. package/docs/plugins/plugin-context.md +3 -3
  13. package/docs/plugins/plugin-descriptor.md +37 -0
  14. package/docs/plugins/plugin-manifest.md +1 -1
  15. package/docusaurus.config.cjs +55 -25
  16. package/package.json +18 -9
  17. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  19. package/plugins/beta/datasets/src/manifest.js +3 -3
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/src/events.js +4 -14
  23. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +1 -3
  24. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  25. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  26. package/plugins/beta/map-styles/src/manifest.js +3 -3
  27. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  28. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  29. package/plugins/beta/use-location/src/manifest.js +7 -7
  30. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  31. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  32. package/plugins/interact/src/InteractInit.jsx +28 -6
  33. package/plugins/interact/src/InteractInit.test.js +19 -5
  34. package/plugins/interact/src/events.js +17 -15
  35. package/plugins/interact/src/events.test.js +25 -16
  36. package/plugins/search/dist/css/index.css +1 -1
  37. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  38. package/plugins/search/dist/esm/index.js +1 -1
  39. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  40. package/plugins/search/dist/umd/index.js +1 -1
  41. package/plugins/search/src/Search.jsx +9 -3
  42. package/plugins/search/src/Search.test.jsx +26 -6
  43. package/plugins/search/src/components/Form/Form.jsx +35 -7
  44. package/plugins/search/src/components/Form/Form.module.scss +27 -0
  45. package/plugins/search/src/components/Form/Form.test.jsx +99 -2
  46. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
  47. package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
  48. package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
  49. package/plugins/search/src/datasets.js +15 -11
  50. package/plugins/search/src/datasets.test.js +17 -2
  51. package/plugins/search/src/events/fetchSuggestions.js +1 -1
  52. package/plugins/search/src/index.js +1 -1
  53. package/plugins/search/src/index.test.js +4 -4
  54. package/plugins/search/src/reducer.js +9 -4
  55. package/plugins/search/src/reducer.test.js +12 -7
  56. package/plugins/search/src/search.scss +5 -1
  57. package/plugins/search/src/utils/parseOsNamesResults.js +18 -2
  58. package/plugins/search/src/utils/parseOsNamesResults.test.js +33 -15
  59. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  60. package/providers/beta/esri/src/appEvents.js +8 -2
  61. package/providers/beta/esri/src/esriProvider.js +25 -17
  62. package/providers/beta/esri/src/mapEvents.js +41 -4
  63. package/providers/beta/esri/src/utils/coords.js +34 -1
  64. package/providers/beta/esri/src/utils/coords.test.js +126 -0
  65. package/providers/beta/esri/src/utils/spatial.js +47 -1
  66. package/providers/beta/esri/src/utils/spatial.test.js +55 -0
  67. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  68. package/providers/maplibre/dist/esm/index.js +1 -1
  69. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  70. package/providers/maplibre/dist/umd/index.js +1 -1
  71. package/providers/maplibre/src/appEvents.js +10 -1
  72. package/providers/maplibre/src/appEvents.test.js +13 -4
  73. package/providers/maplibre/src/index.js +5 -13
  74. package/providers/maplibre/src/index.test.js +34 -15
  75. package/providers/maplibre/src/mapEvents.js +9 -1
  76. package/providers/maplibre/src/maplibreProvider.js +25 -15
  77. package/providers/maplibre/src/maplibreProvider.test.js +28 -2
  78. package/providers/maplibre/src/utils/spatial.js +51 -0
  79. package/providers/maplibre/src/utils/spatial.test.js +47 -0
  80. package/src/App/components/Actions/Actions.module.scss +5 -4
  81. package/src/App/components/MapButton/MapButton.jsx +4 -16
  82. package/src/App/components/MapButton/MapButton.module.scss +12 -12
  83. package/src/App/components/MapButton/MapButton.test.jsx +0 -9
  84. package/src/App/components/Panel/Panel.jsx +6 -6
  85. package/src/App/components/Panel/Panel.test.jsx +14 -15
  86. package/src/App/components/Viewport/MapController.jsx +6 -1
  87. package/src/App/hooks/useLayoutMeasurements.js +1 -1
  88. package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
  89. package/src/App/hooks/useMapProviderOverrides.js +21 -1
  90. package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
  91. package/src/App/hooks/useMarkersAPI.js +5 -3
  92. package/src/App/hooks/useModalPanelBehaviour.js +19 -2
  93. package/src/App/hooks/useModalPanelBehaviour.test.js +84 -60
  94. package/src/App/hooks/useVisibleGeometry.js +100 -0
  95. package/src/App/hooks/useVisibleGeometry.test.js +331 -0
  96. package/src/App/layout/Layout.jsx +5 -5
  97. package/src/App/layout/layout.module.scss +2 -4
  98. package/src/App/registry/panelRegistry.js +1 -10
  99. package/src/App/registry/panelRegistry.test.js +6 -11
  100. package/src/App/renderer/HtmlElementHost.jsx +12 -3
  101. package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
  102. package/src/App/renderer/mapButtons.js +128 -28
  103. package/src/App/renderer/mapButtons.test.js +119 -19
  104. package/src/App/renderer/pluginWrapper.js +3 -2
  105. package/src/App/renderer/slots.js +1 -1
  106. package/src/App/store/AppProvider.jsx +1 -0
  107. package/src/App/store/MapProvider.jsx +18 -5
  108. package/src/App/store/MapProvider.test.jsx +56 -1
  109. package/src/App/store/appActionsMap.js +17 -9
  110. package/src/App/store/appActionsMap.test.js +33 -7
  111. package/src/App/store/appDispatchMiddleware.js +19 -0
  112. package/src/App/store/appDispatchMiddleware.test.js +56 -0
  113. package/src/App/store/mapActionsMap.js +4 -7
  114. package/src/InteractiveMap/InteractiveMap.js +18 -0
  115. package/src/InteractiveMap/InteractiveMap.test.js +12 -0
  116. package/src/config/appConfig.js +17 -15
  117. package/src/config/events.js +41 -4
  118. package/src/config/getInitialOpenPanels.js +2 -2
  119. package/src/config/getInitialOpenPanels.test.js +7 -7
  120. package/src/types.js +22 -11
  121. package/src/utils/getValueForStyle.js +1 -1
@@ -24,11 +24,14 @@ describe('Form', () => {
24
24
  isExpanded: false,
25
25
  value: '',
26
26
  suggestionsVisible: false,
27
+ areSuggestionsVisible: false,
28
+ hasFetchedSuggestions: false,
29
+ suggestions: [],
27
30
  selectedIndex: -1,
28
31
  hasKeyboardFocusWithin: false,
29
32
  },
30
33
  pluginConfig: {
31
- isExpanded: false,
34
+ expanded: false,
32
35
  width: '400px',
33
36
  },
34
37
  appState: {
@@ -44,12 +47,21 @@ describe('Form', () => {
44
47
  handleInputKeyDown: jest.fn(),
45
48
  handleSuggestionClick: jest.fn(),
46
49
  },
50
+ services: {
51
+ announce: jest.fn(),
52
+ },
47
53
  }
48
54
 
49
55
  beforeEach(() => {
50
56
  jest.clearAllMocks()
51
57
  })
52
58
 
59
+ it('renders without error when suggestions is undefined (uses default empty array)', () => {
60
+ const { suggestions: _omit, ...pluginStateWithoutSuggestions } = baseProps.pluginState
61
+ render(<Form {...baseProps} pluginState={pluginStateWithoutSuggestions} />)
62
+ expect(screen.getByRole('search')).toBeInTheDocument()
63
+ })
64
+
53
65
  it('renders the form element with correct role, ID, and base classes', () => {
54
66
  render(<Form {...baseProps} />)
55
67
  const form = screen.getByRole('search')
@@ -62,7 +74,7 @@ describe('Form', () => {
62
74
  render(
63
75
  <Form
64
76
  {...baseProps}
65
- pluginConfig={{ ...baseProps.pluginConfig, isExpanded: true }}
77
+ pluginConfig={{ ...baseProps.pluginConfig, expanded: true }}
66
78
  />
67
79
  )
68
80
  const form = screen.getByRole('search')
@@ -155,4 +167,89 @@ describe('Form', () => {
155
167
  baseProps.appState
156
168
  )
157
169
  })
170
+
171
+ describe('status element and announce', () => {
172
+ // Helper: pluginState representing a completed fetch (hasFetchedSuggestions: true)
173
+ const searchedState = { areSuggestionsVisible: true, hasFetchedSuggestions: true }
174
+
175
+ it('hides the status element when suggestions are not visible', () => {
176
+ const { container } = render(<Form {...baseProps} />)
177
+ expect(container.querySelector('.im-c-search__status')).toBeNull()
178
+ })
179
+
180
+ it('shows the status element when a search returned no results', () => {
181
+ render(
182
+ <Form
183
+ {...baseProps}
184
+ pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [] }}
185
+ />
186
+ )
187
+ expect(screen.getByText('No results available')).toBeInTheDocument()
188
+ })
189
+
190
+ it('hides the status element when there are results', () => {
191
+ const { container } = render(
192
+ <Form
193
+ {...baseProps}
194
+ pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [{ text: 'London' }] }}
195
+ />
196
+ )
197
+ expect(container.querySelector('.im-c-search__status')).toBeNull()
198
+ })
199
+
200
+ it('hides the status element when the fetch has not yet completed', () => {
201
+ const { container } = render(
202
+ <Form
203
+ {...baseProps}
204
+ pluginState={{ ...baseProps.pluginState, areSuggestionsVisible: true, hasFetchedSuggestions: false, suggestions: [] }}
205
+ />
206
+ )
207
+ expect(container.querySelector('.im-c-search__status')).toBeNull()
208
+ })
209
+
210
+ it('announces "No results available" when a search returned no results', () => {
211
+ render(
212
+ <Form
213
+ {...baseProps}
214
+ pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [] }}
215
+ />
216
+ )
217
+ expect(baseProps.services.announce).toHaveBeenCalledWith('No results available')
218
+ })
219
+
220
+ it('announces result count when suggestions are visible and populated', () => {
221
+ render(
222
+ <Form
223
+ {...baseProps}
224
+ pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [{ text: 'A' }, { text: 'B' }] }}
225
+ />
226
+ )
227
+ expect(baseProps.services.announce).toHaveBeenCalledWith('2 results available')
228
+ })
229
+
230
+ it('uses singular "result" for a single result', () => {
231
+ render(
232
+ <Form
233
+ {...baseProps}
234
+ pluginState={{ ...baseProps.pluginState, ...searchedState, suggestions: [{ text: 'A' }] }}
235
+ />
236
+ )
237
+ expect(baseProps.services.announce).toHaveBeenCalledWith('1 result available')
238
+ })
239
+
240
+ it('does not announce when suggestions are not visible', () => {
241
+ render(<Form {...baseProps} />)
242
+ expect(baseProps.services.announce).not.toHaveBeenCalled()
243
+ })
244
+
245
+ it('does not announce when the fetch has not yet completed', () => {
246
+ render(
247
+ <Form
248
+ {...baseProps}
249
+ pluginState={{ ...baseProps.pluginState, areSuggestionsVisible: true, hasFetchedSuggestions: false, suggestions: [] }}
250
+ />
251
+ )
252
+ expect(baseProps.services.announce).not.toHaveBeenCalled()
253
+ })
254
+ })
158
255
  })
@@ -0,0 +1,28 @@
1
+ // src/plugins/search/SubmitButton.jsx
2
+ export const SubmitButton = ({ defaultExpanded, submitIcon }) => {
3
+ return (
4
+ <button
5
+ aria-label="Search"
6
+ className="im-c-map-button im-c-search-submit-button"
7
+ type="submit"
8
+ style={defaultExpanded ? undefined : { display: 'none' }}
9
+ >
10
+ {submitIcon && (
11
+ <svg
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ width="24"
14
+ height="24"
15
+ viewBox="0 0 24 24"
16
+ fill="none"
17
+ stroke="currentColor"
18
+ strokeWidth="2"
19
+ strokeLinecap="round"
20
+ strokeLinejoin="round"
21
+ aria-hidden="true"
22
+ focusable="false"
23
+ dangerouslySetInnerHTML={{ __html: submitIcon }}
24
+ />
25
+ )}
26
+ </button>
27
+ )
28
+ }
@@ -0,0 +1,8 @@
1
+ .im-c-search-submit-button {
2
+ &:before {
3
+ box-shadow: none;
4
+ }
5
+ &[data-focus-visible="true"] {
6
+ z-index: 1;
7
+ }
8
+ }
@@ -0,0 +1,33 @@
1
+ // src/plugins/search/components/SubmitButton/SubmitButton.test.jsx
2
+
3
+ import { render, screen } from '@testing-library/react'
4
+ import { SubmitButton } from './SubmitButton'
5
+
6
+ describe('SubmitButton', () => {
7
+ it('renders a submit button with the correct aria-label', () => {
8
+ render(<SubmitButton defaultExpanded />)
9
+ const button = screen.getByRole('button', { name: 'Search' })
10
+ expect(button).toBeInTheDocument()
11
+ expect(button).toHaveAttribute('type', 'submit')
12
+ })
13
+
14
+ it('hides the button when defaultExpanded is false', () => {
15
+ const { container } = render(<SubmitButton defaultExpanded={false} />)
16
+ expect(container.querySelector('button')).toHaveStyle({ display: 'none' })
17
+ })
18
+
19
+ it('shows the button when defaultExpanded is true', () => {
20
+ render(<SubmitButton defaultExpanded />)
21
+ expect(screen.getByRole('button', { name: 'Search' })).not.toHaveStyle({ display: 'none' })
22
+ })
23
+
24
+ it('renders an svg when submitIcon is provided', () => {
25
+ const { container } = render(<SubmitButton defaultExpanded submitIcon="<path d='M1 1'/>" />)
26
+ expect(container.querySelector('svg')).toBeTruthy()
27
+ })
28
+
29
+ it('does not render an svg when submitIcon is not provided', () => {
30
+ const { container } = render(<SubmitButton defaultExpanded />)
31
+ expect(container.querySelector('svg')).toBeNull()
32
+ })
33
+ })
@@ -1,15 +1,19 @@
1
1
  // src/plugins/search/datasets.js
2
2
  import { parseOsNamesResults } from './utils/parseOsNamesResults.js'
3
3
 
4
- export function createDatasets({ customDatasets = [], osNamesURL, crs }) {
5
-
6
- const defaultDatasets = [{
7
- name: 'osNames',
8
- urlTemplate: osNamesURL,
9
- parseResults: (json, query) => parseOsNamesResults(json, query, crs),
10
- includeRegex: /^[a-zA-Z0-9\s,-]+$/,
11
- excludeRegex: /^(?:[a-z]{2}\s*(?:\d{3}\s*\d{3}|\d{4}\s*\d{4}|\d{5}\s*\d{5})|\d+\s*,?\s*\d+)$/i // NOSONAR - complexity unavoidable for gridref/coordinate matching
12
- }]
13
-
14
- return [...defaultDatasets, ...customDatasets]
4
+ export function createDatasets({ customDatasets = [], osNamesURL, crs, regions = ['england', 'scotland', 'wales'] }) {
5
+
6
+ if (!osNamesURL) {
7
+ return customDatasets
8
+ }
9
+
10
+ const defaultDatasets = [{
11
+ name: 'osNames',
12
+ urlTemplate: osNamesURL,
13
+ parseResults: (json, query) => parseOsNamesResults(json, query, regions, crs),
14
+ includeRegex: /[a-zA-Z0-9]/,
15
+ excludeRegex: /^(?:[a-z]{2}\s*(?:\d{3}\s*\d{3}|\d{4}\s*\d{4}|\d{5}\s*\d{5})|\d+\s*,?\s*\d+)$/i // NOSONAR - complexity unavoidable for gridref/coordinate matching
16
+ }]
17
+
18
+ return [...defaultDatasets, ...customDatasets]
15
19
  }
@@ -11,6 +11,21 @@ describe('createDatasets', () => {
11
11
  jest.clearAllMocks()
12
12
  })
13
13
 
14
+ it('returns only customDatasets if osNamesURL is not provided', () => {
15
+ const custom = [
16
+ { name: 'custom1', urlTemplate: 'https://custom.com' },
17
+ { name: 'custom2', urlTemplate: 'https://custom2.com' }
18
+ ]
19
+ const datasets = createDatasets({ customDatasets: custom, crs })
20
+ expect(datasets).toHaveLength(2)
21
+ expect(datasets).toEqual(custom)
22
+ })
23
+
24
+ it('returns empty array if neither osNamesURL nor customDatasets are provided', () => {
25
+ const datasets = createDatasets({ crs })
26
+ expect(datasets).toEqual([])
27
+ })
28
+
14
29
  it('returns default dataset with correct properties', () => {
15
30
  const datasets = createDatasets({ osNamesURL, crs })
16
31
  expect(datasets).toHaveLength(1)
@@ -18,7 +33,7 @@ describe('createDatasets', () => {
18
33
 
19
34
  expect(ds.name).toBe('osNames')
20
35
  expect(ds.urlTemplate).toBe(osNamesURL)
21
- expect(ds.includeRegex).toEqual(/^[a-zA-Z0-9\s,-]+$/)
36
+ expect(ds.includeRegex).toEqual(/[a-zA-Z0-9]/)
22
37
  expect(ds.excludeRegex).toEqual(
23
38
  /^(?:[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
39
  )
@@ -39,7 +54,7 @@ describe('createDatasets', () => {
39
54
  const query = 'test query'
40
55
 
41
56
  const result = datasets[0].parseResults(json, query)
42
- expect(parseMock).toHaveBeenCalledWith(json, query, crs)
57
+ expect(parseMock).toHaveBeenCalledWith(json, query, ['england', 'scotland', 'wales'], crs)
43
58
  expect(result).toBe('parsed')
44
59
  parseMock.mockRestore()
45
60
  })
@@ -42,7 +42,7 @@ export const fetchSuggestions = async (value, datasets, dispatch, transformReque
42
42
  const sanitisedValue = sanitiseQuery(value)
43
43
 
44
44
  const activeDatasets = datasets.filter(ds => {
45
- const include = ds.includeRegex ? ds.includeRegex.test(sanitisedValue) : true
45
+ const include = ds.includeRegex ? ds.includeRegex.test(value) : true
46
46
  const exclude = ds.excludeRegex ? ds.excludeRegex.test(sanitisedValue) : false
47
47
  return include && !exclude
48
48
  })
@@ -3,7 +3,7 @@ import './search.scss'
3
3
 
4
4
  export default function createPlugin (options = {}) {
5
5
  // If search is open then we need to overirde the mobile slot
6
- if (options.isExpanded) {
6
+ if (options.expanded) {
7
7
  options.manifest = {controls: [{ id: 'search', mobile: { slot: 'banner' }}]}
8
8
  }
9
9
 
@@ -17,16 +17,16 @@ describe('createPlugin', () => {
17
17
  expect(typeof plugin.load).toBe('function')
18
18
  })
19
19
 
20
- it('overrides manifest when isExpanded is true', () => {
21
- const plugin = createPlugin({ isExpanded: true })
22
- expect(plugin.isExpanded).toBe(true)
20
+ it('overrides manifest when expanded is true', () => {
21
+ const plugin = createPlugin({ expanded: true })
22
+ expect(plugin.expanded).toBe(true)
23
23
  expect(plugin.manifest).toEqual({
24
24
  controls: [{ id: 'search', mobile: { slot: 'banner' } }]
25
25
  })
26
26
  })
27
27
 
28
28
  it('spreads custom options correctly', () => {
29
- const custom = { foo: 'bar', isExpanded: false }
29
+ const custom = { foo: 'bar', expanded: false }
30
30
  const plugin = createPlugin(custom)
31
31
  expect(plugin.foo).toBe('bar')
32
32
  expect(plugin.showMarker).toBe(true)
@@ -4,6 +4,7 @@ const initialState = {
4
4
  value: '',
5
5
  suggestions: [],
6
6
  areSuggestionsVisible: false,
7
+ hasFetchedSuggestions: false,
7
8
  selectedIndex: -1
8
9
  }
9
10
 
@@ -12,7 +13,8 @@ const toggleExpanded = (state, payload) => {
12
13
  return {
13
14
  ...state,
14
15
  isExpanded: payload,
15
- areSuggestionsVisible: payload
16
+ areSuggestionsVisible: payload,
17
+ hasFetchedSuggestions: false
16
18
  }
17
19
  }
18
20
 
@@ -43,21 +45,24 @@ const setValue = (state, payload) => {
43
45
  const updateSuggestions = (state, payload) => {
44
46
  return {
45
47
  ...state,
46
- suggestions: payload
48
+ suggestions: payload,
49
+ hasFetchedSuggestions: true
47
50
  }
48
51
  }
49
52
 
50
53
  const showSuggestions = (state) => {
51
54
  return {
52
55
  ...state,
53
- areSuggestionsVisible: true
56
+ areSuggestionsVisible: true,
57
+ hasFetchedSuggestions: false
54
58
  }
55
59
  }
56
60
 
57
61
  const hideSuggestions = (state) => {
58
62
  return {
59
63
  ...state,
60
- areSuggestionsVisible: false
64
+ areSuggestionsVisible: false,
65
+ hasFetchedSuggestions: false
61
66
  }
62
67
  }
63
68
 
@@ -3,15 +3,17 @@
3
3
  import { initialState, actions } from './reducer'
4
4
 
5
5
  describe('search state actions', () => {
6
- it('TOGGLE_EXPANDED sets isExpanded and areSuggestionsVisible', () => {
7
- const state = { ...initialState }
6
+ it('TOGGLE_EXPANDED sets isExpanded and areSuggestionsVisible, and resets hasFetchedSuggestions', () => {
7
+ const state = { ...initialState, hasFetchedSuggestions: true }
8
8
  const newState = actions.TOGGLE_EXPANDED(state, true)
9
9
  expect(newState.isExpanded).toBe(true)
10
10
  expect(newState.areSuggestionsVisible).toBe(true)
11
+ expect(newState.hasFetchedSuggestions).toBe(false)
11
12
 
12
13
  const collapsed = actions.TOGGLE_EXPANDED(state, false)
13
14
  expect(collapsed.isExpanded).toBe(false)
14
15
  expect(collapsed.areSuggestionsVisible).toBe(false)
16
+ expect(collapsed.hasFetchedSuggestions).toBe(false)
15
17
  })
16
18
 
17
19
  it('SET_KEYBOARD_FOCUS_WITHIN sets focus and shows suggestions', () => {
@@ -47,23 +49,26 @@ describe('search state actions', () => {
47
49
  expect(newState.value).toBe('test')
48
50
  })
49
51
 
50
- it('UPDATE_SUGGESTIONS updates the suggestions array', () => {
52
+ it('UPDATE_SUGGESTIONS updates the suggestions array and sets hasFetchedSuggestions', () => {
51
53
  const state = { ...initialState }
52
54
  const suggestions = [{ id: 1 }, { id: 2 }]
53
55
  const newState = actions.UPDATE_SUGGESTIONS(state, suggestions)
54
56
  expect(newState.suggestions).toEqual(suggestions)
57
+ expect(newState.hasFetchedSuggestions).toBe(true)
55
58
  })
56
59
 
57
- it('SHOW_SUGGESTIONS sets areSuggestionsVisible to true', () => {
58
- const state = { ...initialState, areSuggestionsVisible: false }
60
+ it('SHOW_SUGGESTIONS sets areSuggestionsVisible to true and resets hasFetchedSuggestions', () => {
61
+ const state = { ...initialState, areSuggestionsVisible: false, hasFetchedSuggestions: true }
59
62
  const newState = actions.SHOW_SUGGESTIONS(state)
60
63
  expect(newState.areSuggestionsVisible).toBe(true)
64
+ expect(newState.hasFetchedSuggestions).toBe(false)
61
65
  })
62
66
 
63
- it('HIDE_SUGGESTIONS sets areSuggestionsVisible to false', () => {
64
- const state = { ...initialState, areSuggestionsVisible: true }
67
+ it('HIDE_SUGGESTIONS sets areSuggestionsVisible to false and resets hasFetchedSuggestions', () => {
68
+ const state = { ...initialState, areSuggestionsVisible: true, hasFetchedSuggestions: true }
65
69
  const newState = actions.HIDE_SUGGESTIONS(state)
66
70
  expect(newState.areSuggestionsVisible).toBe(false)
71
+ expect(newState.hasFetchedSuggestions).toBe(false)
67
72
  })
68
73
 
69
74
  it('SET_SELECTED updates selectedIndex and visibility', () => {
@@ -1,11 +1,15 @@
1
1
  @use '../../../src/scss/tools/index' as tools;
2
2
 
3
-
4
3
  // Components, using 'CSS in Component' pattern
5
4
  @use './components/Form/Form.module';
6
5
  @use './components/CloseButton/CloseButton.module';
6
+ @use './components/SubmitButton/SubmitButton.module';
7
7
  @use './components/Suggestions/Suggestions.module';
8
8
 
9
+ :root {
10
+ --search-drop-shadow: drop-shadow(0 -2px 20px rgba(0,0,0,.1)) drop-shadow(0 0 12px rgba(0,0,0,.1));
11
+ }
12
+
9
13
  // ===================================================
10
14
  // Component: Search
11
15
  // ===================================================
@@ -10,12 +10,27 @@ const isPostcode = (value) => {
10
10
  return regex.test(value)
11
11
  }
12
12
 
13
+ const removeRegions = (results, regions) => results.filter(r => {
14
+ const country = r?.GAZETTEER_ENTRY?.COUNTRY?.toLowerCase()
15
+ return country && regions.includes(country)
16
+ })
17
+
13
18
  const removeDuplicates = (results) =>
14
19
  Array.from(new Map(results.map(r => [r.GAZETTEER_ENTRY.ID, r])).values())
15
20
 
21
+ const transformGazetteerResult = (result) => {
22
+ const gazetterEntry = result.GAZETTEER_ENTRY
23
+ // Sometimes the NAME1 value can be welsh, so we should use NAME2 instead
24
+ const name = gazetterEntry.NAME2_LANG === 'eng' ? gazetterEntry.NAME2 : gazetterEntry.NAME1
25
+ // Force NAME1 to be the english value
26
+ result.GAZETTEER_ENTRY.NAME1 = name
27
+ result.GAZETTEER_ENTRY.NAME1_LANG = 'eng'
28
+ return result
29
+ }
30
+
16
31
  const removeTenuousResults = (results, query) => {
17
32
  const words = query.toLowerCase().replace(/,/g, '').split(' ')
18
- return results.filter(l => words.some(w => l.GAZETTEER_ENTRY.NAME1.toLowerCase().includes(w) || isPostcode(query)))
33
+ return results.map(transformGazetteerResult).filter(l => words.some(w => l.GAZETTEER_ENTRY.NAME1.toLowerCase().includes(w) || isPostcode(query)))
19
34
  }
20
35
 
21
36
  const markString = (string, find) => {
@@ -86,13 +101,14 @@ const label = (query, { NAME1, COUNTY_UNITARY, DISTRICT_BOROUGH, POSTCODE_DISTRI
86
101
  }
87
102
  }
88
103
 
89
- const parseOsNamesResults = (json, query, crs) => {
104
+ const parseOsNamesResults = (json, query, regions, crs) => {
90
105
  if (!json || json.error || json.header?.totalresults === 0) {
91
106
  return []
92
107
  }
93
108
  let results = json.results
94
109
  results = removeTenuousResults(results, query)
95
110
  results = removeDuplicates(results)
111
+ results = removeRegions(results, regions)
96
112
  results = results.slice(0, MAX_RESULTS)
97
113
 
98
114
  return results.map(l => ({
@@ -2,7 +2,6 @@
2
2
  * @jest-environment jsdom
3
3
  */
4
4
  import { parseOsNamesResults, point } from './parseOsNamesResults.js'
5
- import OsGridRef from 'geodesy/osgridref.js'
6
5
 
7
6
  // Mock OsGridRef so we can control toLatLon outputs
8
7
  jest.mock('geodesy/osgridref.js', () => {
@@ -14,6 +13,8 @@ jest.mock('geodesy/osgridref.js', () => {
14
13
  })
15
14
 
16
15
  describe('osNamesUtils', () => {
16
+ const regions = ['england', 'scotland', 'wales']
17
+
17
18
  const sampleEntry = {
18
19
  GAZETTEER_ENTRY: {
19
20
  ID: 1,
@@ -22,6 +23,7 @@ describe('osNamesUtils', () => {
22
23
  DISTRICT_BOROUGH: 'Camden',
23
24
  POSTCODE_DISTRICT: 'WC1',
24
25
  LOCAL_TYPE: 'Town',
26
+ COUNTRY: 'England',
25
27
  MBR_XMIN: 1000,
26
28
  MBR_YMIN: 2000,
27
29
  MBR_XMAX: 3000,
@@ -32,9 +34,9 @@ describe('osNamesUtils', () => {
32
34
  }
33
35
 
34
36
  test('returns empty array for null/invalid/error results', () => {
35
- expect(parseOsNamesResults(null, 'x', 'EPSG:27700')).toEqual([])
36
- expect(parseOsNamesResults({ error: true }, 'x', 'EPSG:27700')).toEqual([])
37
- expect(parseOsNamesResults({ header: { totalresults: 0 } }, 'x', 'EPSG:27700')).toEqual([])
37
+ expect(parseOsNamesResults(null, 'x', regions, 'EPSG:27700')).toEqual([])
38
+ expect(parseOsNamesResults({ error: true }, 'x', regions, 'EPSG:27700')).toEqual([])
39
+ expect(parseOsNamesResults({ header: { totalresults: 0 } }, 'x', regions, 'EPSG:27700')).toEqual([])
38
40
  })
39
41
 
40
42
  test('removes tenuous results when query does not match', () => {
@@ -42,14 +44,14 @@ describe('osNamesUtils', () => {
42
44
  { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, NAME1: 'Bristol', ID: 2 } }
43
45
  ]
44
46
  const json = { results }
45
- const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
47
+ const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
46
48
  expect(output).toHaveLength(0)
47
49
  })
48
50
 
49
51
  test('removes duplicate IDs', () => {
50
52
  const dup = { ...sampleEntry, GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY } }
51
53
  const json = { results: [sampleEntry, dup] }
52
- const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
54
+ const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
53
55
  expect(output).toHaveLength(1)
54
56
  })
55
57
 
@@ -58,13 +60,13 @@ describe('osNamesUtils', () => {
58
60
  GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, ID: i }
59
61
  }))
60
62
  const json = { results: manyResults }
61
- const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
63
+ const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
62
64
  expect(output).toHaveLength(8)
63
65
  })
64
66
 
65
67
  test('bounds returns raw OSGB values for EPSG:27700', () => {
66
68
  const json = { results: [sampleEntry] }
67
- const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
69
+ const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
68
70
  expect(output[0].bounds).toEqual([
69
71
  sampleEntry.GAZETTEER_ENTRY.MBR_XMIN,
70
72
  sampleEntry.GAZETTEER_ENTRY.MBR_YMIN,
@@ -79,7 +81,7 @@ describe('osNamesUtils', () => {
79
81
 
80
82
  test('bounds converts to WGS84 for EPSG:4326', () => {
81
83
  const json = { results: [sampleEntry] }
82
- const output = parseOsNamesResults(json, 'London', 'EPSG:4326')
84
+ const output = parseOsNamesResults(json, 'London', regions, 'EPSG:4326')
83
85
  const expectedBounds = [
84
86
  Math.round(1000 / 1e5 * 1e6) / 1e6,
85
87
  Math.round(2000 / 1e5 * 1e6) / 1e6,
@@ -96,7 +98,7 @@ describe('osNamesUtils', () => {
96
98
 
97
99
  test('label generates marked text', () => {
98
100
  const json = { results: [sampleEntry] }
99
- const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
101
+ const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
100
102
  expect(output[0].text).toContain('London')
101
103
  expect(output[0].marked).toContain('<mark>')
102
104
  })
@@ -106,7 +108,7 @@ describe('osNamesUtils', () => {
106
108
  GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, MBR_XMIN: null, MBR_YMIN: null }
107
109
  }
108
110
  const json = { results: [entry] }
109
- const output = parseOsNamesResults(json, 'London', 'EPSG:27700')
111
+ const output = parseOsNamesResults(json, 'London', regions, 'EPSG:27700')
110
112
  expect(output[0].bounds).toEqual([
111
113
  entry.GAZETTEER_ENTRY.GEOMETRY_X - 500,
112
114
  entry.GAZETTEER_ENTRY.GEOMETRY_Y - 500,
@@ -117,24 +119,40 @@ describe('osNamesUtils', () => {
117
119
 
118
120
  test('label falls back to DISTRICT_BOROUGH when COUNTY_UNITARY is absent', () => {
119
121
  const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, COUNTY_UNITARY: null } }
120
- const output = parseOsNamesResults({ results: [entry] }, 'London', 'EPSG:27700')
122
+ const output = parseOsNamesResults({ results: [entry] }, 'London', regions, 'EPSG:27700')
121
123
  expect(output[0].text).toContain(sampleEntry.GAZETTEER_ENTRY.DISTRICT_BOROUGH)
122
124
  })
123
125
 
124
126
  test('label omits qualifier for City type', () => {
125
127
  const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, LOCAL_TYPE: 'City' } }
126
- const output = parseOsNamesResults({ results: [entry] }, 'London', 'EPSG:27700')
128
+ const output = parseOsNamesResults({ results: [entry] }, 'London', regions, 'EPSG:27700')
127
129
  expect(output[0].text).toBe('London')
128
130
  })
129
131
 
132
+ /**
133
+ * Cover transformGazetteerResult branches
134
+ */
135
+
136
+ test('uses NAME2 when NAME2_LANG is eng', () => {
137
+ const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, NAME1: 'Caerdydd', NAME2: 'Cardiff', NAME2_LANG: 'eng' }}
138
+ const output = parseOsNamesResults({ results: [entry] }, 'Cardiff', regions, 'EPSG:27700')
139
+ expect(output[0].text).toContain('Cardiff')
140
+ })
141
+
142
+ test('falls back to NAME1 when NAME2_LANG is not eng', () => {
143
+ const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY, NAME1: 'London', NAME2: 'Londres', NAME2_LANG: 'fra' }}
144
+ const output = parseOsNamesResults({ results: [entry] }, 'London', regions, 'EPSG:27700')
145
+ expect(output[0].text).toContain('London')
146
+ })
147
+
130
148
  test('throws error for unsupported CRS', () => {
131
149
  const entry = { GAZETTEER_ENTRY: { ...sampleEntry.GAZETTEER_ENTRY } }
132
150
  const json = { results: [entry] }
133
- expect(() => parseOsNamesResults(json, 'London', 'EPSG:9999')).toThrow('Unsupported CRS')
151
+ expect(() => parseOsNamesResults(json, 'London', regions, 'EPSG:9999')).toThrow('Unsupported CRS')
134
152
  })
135
153
 
136
154
  test('point function throws error for unsupported CRS', () => {
137
155
  const coords = { GEOMETRY_X: 1500, GEOMETRY_Y: 2500 }
138
156
  expect(() => point('EPSG:9999', coords)).toThrow('Unsupported CRS: EPSG:9999')
139
157
  })
140
- })
158
+ })