@defra/interactive-map 0.0.9-alpha → 0.0.11-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 (120) hide show
  1. package/DOCS_README.md +39 -0
  2. package/README.md +1 -1
  3. package/dist/css/index.css +1 -1
  4. package/dist/esm/im-core.js +1 -1
  5. package/dist/esm/im-shell.js +1 -1
  6. package/dist/umd/im-core.js +1 -1
  7. package/dist/umd/index.js +1 -1
  8. package/docs/api/button-definition.md +21 -3
  9. package/docs/api/panel-definition.md +10 -12
  10. package/docs/api.md +81 -8
  11. package/docs/architecture/architecture-diagrams.md +1 -3
  12. package/docs/architecture/diagrams-viewer.mdx +12 -0
  13. package/docs/demo.mdx +70 -0
  14. package/docs/govuk-prototype.md +23 -0
  15. package/docs/index.md +19 -0
  16. package/docs/plugins/plugin-context.md +3 -3
  17. package/docs/plugins/plugin-manifest.md +1 -1
  18. package/docusaurus.config.cjs +136 -0
  19. package/mise.toml +2 -0
  20. package/package.json +27 -5
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/beta/datasets/src/manifest.js +3 -3
  24. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  25. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
  26. package/plugins/beta/draw-es/src/events.js +2 -2
  27. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  28. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  29. package/plugins/beta/draw-ml/src/api/newLine.js +2 -2
  30. package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
  31. package/plugins/beta/draw-ml/src/events.js +18 -10
  32. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  33. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  34. package/plugins/beta/map-styles/src/manifest.js +3 -3
  35. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  36. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  37. package/plugins/beta/use-location/src/manifest.js +7 -7
  38. package/plugins/search/dist/css/index.css +1 -1
  39. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  40. package/plugins/search/dist/esm/index.js +1 -1
  41. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  42. package/plugins/search/dist/umd/index.js +1 -1
  43. package/plugins/search/src/Search.jsx +9 -3
  44. package/plugins/search/src/Search.test.jsx +190 -0
  45. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
  46. package/plugins/search/src/components/Form/Form.jsx +35 -7
  47. package/plugins/search/src/components/Form/Form.module.scss +27 -0
  48. package/plugins/search/src/components/Form/Form.test.jsx +255 -0
  49. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
  50. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
  51. package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
  52. package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
  53. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
  54. package/plugins/search/src/datasets.js +15 -11
  55. package/plugins/search/src/datasets.test.js +61 -0
  56. package/plugins/search/src/events/fetchSuggestions.js +1 -1
  57. package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
  58. package/plugins/search/src/events/formHandlers.test.js +232 -0
  59. package/plugins/search/src/events/index.test.js +118 -0
  60. package/plugins/search/src/events/inputHandlers.test.js +104 -0
  61. package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
  62. package/plugins/search/src/index.js +1 -1
  63. package/plugins/search/src/index.test.js +47 -0
  64. package/plugins/search/src/reducer.js +9 -4
  65. package/plugins/search/src/reducer.test.js +85 -0
  66. package/plugins/search/src/search.scss +5 -1
  67. package/plugins/search/src/utils/parseOsNamesResults.js +20 -3
  68. package/plugins/search/src/utils/parseOsNamesResults.test.js +158 -0
  69. package/plugins/search/src/utils/updateMap.test.js +52 -0
  70. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  71. package/providers/beta/esri/src/appEvents.js +8 -2
  72. package/providers/beta/esri/src/esriProvider.js +6 -14
  73. package/providers/beta/esri/src/mapEvents.js +7 -1
  74. package/providers/beta/esri/src/utils/coords.js +33 -1
  75. package/providers/beta/esri/src/utils/coords.test.js +126 -0
  76. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  77. package/providers/maplibre/dist/esm/index.js +1 -1
  78. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/umd/index.js +1 -1
  80. package/providers/maplibre/src/appEvents.js +10 -1
  81. package/providers/maplibre/src/appEvents.test.js +13 -4
  82. package/providers/maplibre/src/index.js +5 -13
  83. package/providers/maplibre/src/index.test.js +34 -15
  84. package/providers/maplibre/src/mapEvents.js +9 -1
  85. package/providers/maplibre/src/maplibreProvider.js +14 -15
  86. package/providers/maplibre/src/maplibreProvider.test.js +14 -1
  87. package/providers/maplibre/src/utils/spatial.js +11 -0
  88. package/providers/maplibre/src/utils/spatial.test.js +12 -0
  89. package/src/App/components/Actions/Actions.module.scss +5 -4
  90. package/src/App/components/MapButton/MapButton.jsx +4 -16
  91. package/src/App/components/MapButton/MapButton.module.scss +12 -12
  92. package/src/App/components/MapButton/MapButton.test.jsx +0 -9
  93. package/src/App/components/Panel/Panel.jsx +6 -6
  94. package/src/App/components/Panel/Panel.test.jsx +14 -15
  95. package/src/App/components/Viewport/MapController.jsx +2 -1
  96. package/src/App/hooks/useLayoutMeasurements.js +1 -1
  97. package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
  98. package/src/App/hooks/useMapProviderOverrides.js +21 -1
  99. package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
  100. package/src/App/layout/Layout.jsx +4 -4
  101. package/src/App/layout/layout.module.scss +1 -0
  102. package/src/App/registry/panelRegistry.js +1 -10
  103. package/src/App/registry/panelRegistry.test.js +6 -11
  104. package/src/App/renderer/HtmlElementHost.jsx +11 -3
  105. package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
  106. package/src/App/renderer/mapButtons.js +128 -28
  107. package/src/App/renderer/mapButtons.test.js +119 -19
  108. package/src/App/store/MapProvider.jsx +18 -5
  109. package/src/App/store/MapProvider.test.jsx +56 -1
  110. package/src/App/store/appActionsMap.js +17 -9
  111. package/src/App/store/appActionsMap.test.js +33 -7
  112. package/src/App/store/mapActionsMap.js +4 -7
  113. package/src/InteractiveMap/InteractiveMap.js +18 -0
  114. package/src/InteractiveMap/InteractiveMap.test.js +12 -0
  115. package/src/config/appConfig.js +17 -15
  116. package/src/config/events.js +41 -4
  117. package/src/config/getInitialOpenPanels.js +2 -2
  118. package/src/config/getInitialOpenPanels.test.js +7 -7
  119. package/src/types.js +13 -11
  120. package/src/utils/getValueForStyle.js +1 -1
@@ -1,6 +1,20 @@
1
1
  // src/plugins/search/Form.jsx
2
+ import { useEffect } from 'react'
2
3
  import { Suggestions } from '../Suggestions/Suggestions'
3
4
 
5
+ const getResultMessage = (count) => {
6
+ if (count === 0) {
7
+ return 'No results available'
8
+ }
9
+ const plural = count === 1 ? 'result' : 'results'
10
+ return `${count} ${plural} available`
11
+ }
12
+
13
+ const getFormStyle = (pluginConfig, pluginState, appState) => ({
14
+ display: pluginConfig.expanded || pluginState.isExpanded ? 'flex' : undefined,
15
+ ...(appState.breakpoint !== 'mobile' && pluginConfig?.width && { width: pluginConfig.width }),
16
+ })
17
+
4
18
  export const Form = ({
5
19
  id,
6
20
  pluginState,
@@ -8,24 +22,34 @@ export const Form = ({
8
22
  appState,
9
23
  inputRef,
10
24
  events,
25
+ services,
11
26
  children, // For SearchClose
12
27
  }) => {
28
+ const { areSuggestionsVisible, hasFetchedSuggestions, suggestions = [] } = pluginState
29
+
30
+ // Announce when a fetch has completed (hasFetchedSuggestions flips to true),
31
+ // not when the input is merely focused/clicked (SHOW_SUGGESTIONS resets it to false).
32
+ useEffect(() => {
33
+ if (!areSuggestionsVisible || !hasFetchedSuggestions) {
34
+ return
35
+ }
36
+ services.announce(getResultMessage(suggestions.length))
37
+ }, [suggestions, hasFetchedSuggestions])
13
38
 
14
39
  const classNames = [
15
40
  'im-c-search-form',
16
- pluginConfig.isExpanded && 'im-c-search-form--default-expanded',
41
+ pluginConfig.expanded && 'im-c-search-form--default-expanded',
17
42
  'im-c-panel'
18
43
  ].filter(Boolean).join(' ')
19
44
 
45
+ const showNoResults = areSuggestionsVisible && hasFetchedSuggestions && !suggestions.length
46
+
20
47
  return (
21
48
  <form
22
49
  id={`${id}-search-form`}
23
50
  role="search"
24
51
  className={classNames}
25
- style={{
26
- display: pluginConfig.isExpanded || pluginState.isExpanded ? 'flex' : undefined,
27
- ...(appState.breakpoint !== 'mobile' && pluginConfig?.width && { width: pluginConfig.width }),
28
- }}
52
+ style={getFormStyle(pluginConfig, pluginState, appState)}
29
53
  aria-controls={`${id}-viewport`}
30
54
  onSubmit={(e) => events.handleSubmit(e, appState, pluginState)}
31
55
  >
@@ -65,7 +89,11 @@ export const Form = ({
65
89
  {/* Close button passed as child */}
66
90
  {children}
67
91
  </div>
68
-
92
+ {showNoResults && (
93
+ <div className="im-c-search__status" aria-hidden="true">
94
+ No results available
95
+ </div>
96
+ )}
69
97
  <Suggestions
70
98
  id={id}
71
99
  appState={appState}
@@ -74,4 +102,4 @@ export const Form = ({
74
102
  />
75
103
  </form>
76
104
  )
77
- }
105
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  &:before {
8
8
  border-radius: 0;
9
+ box-shadow: none;
9
10
  }
10
11
  }
11
12
 
@@ -15,15 +16,41 @@
15
16
  top: 0;
16
17
  right: 0;
17
18
  height: auto;
19
+ overflow: visible;
20
+ filter: var(--search-drop-shadow);
18
21
  }
19
22
 
20
23
  .im-o-app--tablet .im-c-search-form,
21
24
  .im-o-app--desktop .im-c-search-form {
25
+ position: relative;
26
+ overflow: visible;
22
27
  border-radius: var(--button-border-radius);
28
+ box-shadow: none;
29
+ filter: var(--search-drop-shadow);
23
30
 
24
31
  &:before {
25
32
  border-radius: var(--button-border-radius);
26
33
  }
34
+
35
+ .im-c-search-suggestions {
36
+ position: absolute;
37
+ top: calc(100% + 1px);
38
+ left: 0;
39
+ right: 0;
40
+ z-index: 1;
41
+ }
42
+ }
43
+
44
+ // Status
45
+ .im-c-search__status {
46
+ position: absolute;
47
+ top: calc(100% + 1px);
48
+ left: 0;
49
+ right: 0;
50
+ padding: var(--divider-gap) calc(var(--divider-gap) + 2px);
51
+ background-color: var(--background-color);
52
+ border-bottom-left-radius: var(--button-border-radius);
53
+ border-bottom-right-radius: var(--button-border-radius);
27
54
  }
28
55
 
29
56
  // Default expanded
@@ -0,0 +1,255 @@
1
+ // src/plugins/search/Form.test.jsx
2
+
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { Form } from './Form'
5
+
6
+ // Mock Suggestions to simulate user interactions
7
+ jest.mock('../Suggestions/Suggestions', () => ({
8
+ Suggestions: ({ handleSuggestionClick, id }) => (
9
+ <button
10
+ data-testid="suggestion"
11
+ onClick={() => handleSuggestionClick('clicked-suggestion')}
12
+ id={`${id}-search-suggestions`}
13
+ >
14
+ Suggestion
15
+ </button>
16
+ ),
17
+ }))
18
+
19
+ describe('Form', () => {
20
+ const baseProps = {
21
+ id: 'test',
22
+ inputRef: { current: null },
23
+ pluginState: {
24
+ isExpanded: false,
25
+ value: '',
26
+ suggestionsVisible: false,
27
+ areSuggestionsVisible: false,
28
+ hasFetchedSuggestions: false,
29
+ suggestions: [],
30
+ selectedIndex: -1,
31
+ hasKeyboardFocusWithin: false,
32
+ },
33
+ pluginConfig: {
34
+ expanded: false,
35
+ width: '400px',
36
+ },
37
+ appState: {
38
+ breakpoint: 'desktop',
39
+ interfaceType: 'keyboard',
40
+ },
41
+ events: {
42
+ handleSubmit: jest.fn(),
43
+ handleInputClick: jest.fn(),
44
+ handleInputChange: jest.fn(),
45
+ handleInputFocus: jest.fn(),
46
+ handleInputBlur: jest.fn(),
47
+ handleInputKeyDown: jest.fn(),
48
+ handleSuggestionClick: jest.fn(),
49
+ },
50
+ services: {
51
+ announce: jest.fn(),
52
+ },
53
+ }
54
+
55
+ beforeEach(() => {
56
+ jest.clearAllMocks()
57
+ })
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
+
65
+ it('renders the form element with correct role, ID, and base classes', () => {
66
+ render(<Form {...baseProps} />)
67
+ const form = screen.getByRole('search')
68
+ expect(form).toHaveAttribute('id', 'test-search-form')
69
+ expect(form.className).toContain('im-c-search-form')
70
+ expect(form.className).toContain('im-c-panel')
71
+ })
72
+
73
+ it('applies expanded styles and width when the pluginConfig is expanded', () => {
74
+ render(
75
+ <Form
76
+ {...baseProps}
77
+ pluginConfig={{ ...baseProps.pluginConfig, expanded: true }}
78
+ />
79
+ )
80
+ const form = screen.getByRole('search')
81
+ expect(form).toHaveStyle({ display: 'flex', width: '400px' })
82
+ expect(form.className).toContain('im-c-search-form--default-expanded')
83
+ })
84
+
85
+ it('calls handleSubmit with the event, appState, and pluginState when form is submitted', () => {
86
+ render(<Form {...baseProps} />)
87
+ const form = screen.getByRole('search')
88
+ fireEvent.submit(form)
89
+ expect(baseProps.events.handleSubmit).toHaveBeenCalledTimes(1)
90
+ expect(baseProps.events.handleSubmit.mock.calls[0][1]).toBe(baseProps.appState)
91
+ expect(baseProps.events.handleSubmit.mock.calls[0][2]).toBe(baseProps.pluginState)
92
+ })
93
+
94
+ it('renders the search input with correct ARIA attributes when suggestions are visible and an item is selected', () => {
95
+ render(
96
+ <Form
97
+ {...baseProps}
98
+ pluginState={{
99
+ ...baseProps.pluginState,
100
+ suggestionsVisible: true,
101
+ selectedIndex: 2,
102
+ }}
103
+ />
104
+ )
105
+ const input = screen.getByRole('combobox')
106
+ expect(input).toHaveAttribute('aria-expanded', 'true')
107
+ expect(input).toHaveAttribute('aria-activedescendant', 'test-search-suggestion-2')
108
+ expect(input).toHaveAttribute('aria-controls', 'test-search-suggestions')
109
+ })
110
+
111
+ it('adds keyboard focus class when the input container has focus within', () => {
112
+ render(
113
+ <Form
114
+ {...baseProps}
115
+ pluginState={{ ...baseProps.pluginState, hasKeyboardFocusWithin: true }}
116
+ />
117
+ )
118
+ const container = screen.getByRole('search').querySelector('.im-c-search__input-container')
119
+ expect(container.className).toContain('im-c-search__input-container--keyboard-focus-within')
120
+ })
121
+
122
+ it('does not set aria-describedby when the search input has a value', () => {
123
+ render(
124
+ <Form
125
+ {...baseProps}
126
+ pluginState={{ ...baseProps.pluginState, value: 'something' }}
127
+ />
128
+ )
129
+ const input = screen.getByRole('combobox')
130
+ expect(input).not.toHaveAttribute('aria-describedby')
131
+ })
132
+
133
+ it('wires input event handlers correctly (click, change, focus, blur, keydown)', () => {
134
+ render(<Form {...baseProps} />)
135
+ const input = screen.getByRole('combobox')
136
+ fireEvent.click(input)
137
+ fireEvent.change(input, { target: { value: 'abc' } })
138
+ fireEvent.focus(input)
139
+ fireEvent.blur(input)
140
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
141
+ expect(baseProps.events.handleInputClick).toHaveBeenCalled()
142
+ expect(baseProps.events.handleInputChange).toHaveBeenCalled()
143
+ expect(baseProps.events.handleInputFocus).toHaveBeenCalledWith('keyboard')
144
+ expect(baseProps.events.handleInputBlur).toHaveBeenCalledWith('keyboard')
145
+ expect(baseProps.events.handleInputKeyDown).toHaveBeenCalled()
146
+ })
147
+
148
+ it('renders children passed into the input container (e.g., CloseButton)', () => {
149
+ render(
150
+ <Form {...baseProps}>
151
+ <div data-testid="close-button" />
152
+ </Form>
153
+ )
154
+ expect(screen.getByTestId('close-button')).toBeInTheDocument()
155
+ })
156
+
157
+ it('renders the Suggestions component', () => {
158
+ render(<Form {...baseProps} />)
159
+ expect(screen.getByTestId('suggestion')).toBeInTheDocument()
160
+ })
161
+
162
+ it('calls events.handleSuggestionClick when a suggestion is clicked', () => {
163
+ render(<Form {...baseProps} />)
164
+ fireEvent.click(screen.getByTestId('suggestion'))
165
+ expect(baseProps.events.handleSuggestionClick).toHaveBeenCalledWith(
166
+ 'clicked-suggestion',
167
+ baseProps.appState
168
+ )
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
+ })
255
+ })
@@ -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
+ })
@@ -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
+ })
@@ -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
+ })
@@ -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
  }
@@ -0,0 +1,61 @@
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 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
+
29
+ it('returns default dataset with correct properties', () => {
30
+ const datasets = createDatasets({ osNamesURL, crs })
31
+ expect(datasets).toHaveLength(1)
32
+ const ds = datasets[0]
33
+
34
+ expect(ds.name).toBe('osNames')
35
+ expect(ds.urlTemplate).toBe(osNamesURL)
36
+ expect(ds.includeRegex).toEqual(/[a-zA-Z0-9]/)
37
+ expect(ds.excludeRegex).toEqual(
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
39
+ )
40
+ expect(typeof ds.parseResults).toBe('function')
41
+ })
42
+
43
+ it('merge custom datasets with default dataset', () => {
44
+ const custom = [{ name: 'custom1', urlTemplate: 'https://custom.com' }]
45
+ const datasets = createDatasets({ osNamesURL, crs, customDatasets: custom })
46
+ expect(datasets).toHaveLength(2)
47
+ expect(datasets[1]).toEqual(custom[0])
48
+ })
49
+
50
+ it('parseResults calls parseOsNamesResults with correct arguments', () => {
51
+ const parseMock = jest.spyOn(parseModule, 'parseOsNamesResults').mockReturnValue('parsed')
52
+ const datasets = createDatasets({ osNamesURL, crs })
53
+ const json = { some: 'data' }
54
+ const query = 'test query'
55
+
56
+ const result = datasets[0].parseResults(json, query)
57
+ expect(parseMock).toHaveBeenCalledWith(json, query, ['england', 'scotland', 'wales'], crs)
58
+ expect(result).toBe('parsed')
59
+ parseMock.mockRestore()
60
+ })
61
+ })
@@ -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
  })