@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.
- package/DOCS_README.md +39 -0
- package/README.md +1 -1
- 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/button-definition.md +21 -3
- package/docs/api/panel-definition.md +10 -12
- package/docs/api.md +81 -8
- package/docs/architecture/architecture-diagrams.md +1 -3
- package/docs/architecture/diagrams-viewer.mdx +12 -0
- package/docs/demo.mdx +70 -0
- package/docs/govuk-prototype.md +23 -0
- package/docs/index.md +19 -0
- package/docs/plugins/plugin-context.md +3 -3
- package/docs/plugins/plugin-manifest.md +1 -1
- package/docusaurus.config.cjs +136 -0
- package/mise.toml +2 -0
- package/package.json +27 -5
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/src/manifest.js +3 -3
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/api/newPolygon.js +1 -3
- package/plugins/beta/draw-es/src/events.js +2 -2
- 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-ml/src/api/newLine.js +2 -2
- package/plugins/beta/draw-ml/src/api/newPolygon.js +2 -2
- package/plugins/beta/draw-ml/src/events.js +18 -10
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/src/manifest.js +3 -3
- package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
- package/plugins/beta/use-location/src/manifest.js +7 -7
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/esm/index.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/index.js +1 -1
- package/plugins/search/src/Search.jsx +9 -3
- package/plugins/search/src/Search.test.jsx +190 -0
- package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +67 -0
- package/plugins/search/src/components/Form/Form.jsx +35 -7
- package/plugins/search/src/components/Form/Form.module.scss +27 -0
- package/plugins/search/src/components/Form/Form.test.jsx +255 -0
- package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +47 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +28 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.module.scss +8 -0
- package/plugins/search/src/components/SubmitButton/SubmitButton.test.jsx +33 -0
- package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +79 -0
- package/plugins/search/src/datasets.js +15 -11
- package/plugins/search/src/datasets.test.js +61 -0
- package/plugins/search/src/events/fetchSuggestions.js +1 -1
- package/plugins/search/src/events/fetchSuggestions.test.js +212 -0
- package/plugins/search/src/events/formHandlers.test.js +232 -0
- package/plugins/search/src/events/index.test.js +118 -0
- package/plugins/search/src/events/inputHandlers.test.js +104 -0
- package/plugins/search/src/events/suggestionHandlers.test.js +166 -0
- package/plugins/search/src/index.js +1 -1
- package/plugins/search/src/index.test.js +47 -0
- package/plugins/search/src/reducer.js +9 -4
- package/plugins/search/src/reducer.test.js +85 -0
- package/plugins/search/src/search.scss +5 -1
- package/plugins/search/src/utils/parseOsNamesResults.js +20 -3
- package/plugins/search/src/utils/parseOsNamesResults.test.js +158 -0
- package/plugins/search/src/utils/updateMap.test.js +52 -0
- package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
- package/providers/beta/esri/src/appEvents.js +8 -2
- package/providers/beta/esri/src/esriProvider.js +6 -14
- package/providers/beta/esri/src/mapEvents.js +7 -1
- package/providers/beta/esri/src/utils/coords.js +33 -1
- package/providers/beta/esri/src/utils/coords.test.js +126 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/esm/index.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/appEvents.js +10 -1
- package/providers/maplibre/src/appEvents.test.js +13 -4
- package/providers/maplibre/src/index.js +5 -13
- package/providers/maplibre/src/index.test.js +34 -15
- package/providers/maplibre/src/mapEvents.js +9 -1
- package/providers/maplibre/src/maplibreProvider.js +14 -15
- package/providers/maplibre/src/maplibreProvider.test.js +14 -1
- package/providers/maplibre/src/utils/spatial.js +11 -0
- package/providers/maplibre/src/utils/spatial.test.js +12 -0
- package/src/App/components/Actions/Actions.module.scss +5 -4
- package/src/App/components/MapButton/MapButton.jsx +4 -16
- package/src/App/components/MapButton/MapButton.module.scss +12 -12
- package/src/App/components/MapButton/MapButton.test.jsx +0 -9
- package/src/App/components/Panel/Panel.jsx +6 -6
- package/src/App/components/Panel/Panel.test.jsx +14 -15
- package/src/App/components/Viewport/MapController.jsx +2 -1
- package/src/App/hooks/useLayoutMeasurements.js +1 -1
- package/src/App/hooks/useLayoutMeasurements.test.js +1 -1
- package/src/App/hooks/useMapProviderOverrides.js +21 -1
- package/src/App/hooks/useMapProviderOverrides.test.js +51 -2
- package/src/App/layout/Layout.jsx +4 -4
- package/src/App/layout/layout.module.scss +1 -0
- package/src/App/registry/panelRegistry.js +1 -10
- package/src/App/registry/panelRegistry.test.js +6 -11
- package/src/App/renderer/HtmlElementHost.jsx +11 -3
- package/src/App/renderer/HtmlElementHost.test.jsx +89 -0
- package/src/App/renderer/mapButtons.js +128 -28
- package/src/App/renderer/mapButtons.test.js +119 -19
- package/src/App/store/MapProvider.jsx +18 -5
- package/src/App/store/MapProvider.test.jsx +56 -1
- package/src/App/store/appActionsMap.js +17 -9
- package/src/App/store/appActionsMap.test.js +33 -7
- package/src/App/store/mapActionsMap.js +4 -7
- package/src/InteractiveMap/InteractiveMap.js +18 -0
- package/src/InteractiveMap/InteractiveMap.test.js +12 -0
- package/src/config/appConfig.js +17 -15
- package/src/config/events.js +41 -4
- package/src/config/getInitialOpenPanels.js +2 -2
- package/src/config/getInitialOpenPanels.test.js +7 -7
- package/src/types.js +13 -11
- 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.
|
|
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,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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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(
|
|
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
|
})
|