govuk_publishing_components 43.3.0 → 43.4.1

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/govuk_publishing_components/icon-autocomplete-search-suggestion.svg +4 -0
  3. data/app/assets/javascripts/govuk_publishing_components/components/search-with-autocomplete.js +123 -0
  4. data/app/assets/stylesheets/govuk_publishing_components/components/_layout-super-navigation-header.scss +5 -4
  5. data/app/assets/stylesheets/govuk_publishing_components/components/_phase-banner.scss +0 -2
  6. data/app/assets/stylesheets/govuk_publishing_components/components/_search-with-autocomplete.scss +200 -0
  7. data/app/views/govuk_publishing_components/components/_layout_header.html.erb +16 -40
  8. data/app/views/govuk_publishing_components/components/_search.html.erb +16 -14
  9. data/app/views/govuk_publishing_components/components/_search_with_autocomplete.html.erb +27 -0
  10. data/app/views/govuk_publishing_components/components/docs/layout_header.yml +0 -41
  11. data/app/views/govuk_publishing_components/components/docs/phase_banner.yml +4 -0
  12. data/app/views/govuk_publishing_components/components/docs/search_with_autocomplete.yml +61 -0
  13. data/lib/govuk_publishing_components/version.rb +1 -1
  14. data/node_modules/accessible-autocomplete/CHANGELOG.md +390 -0
  15. data/node_modules/accessible-autocomplete/CODEOWNERS +2 -0
  16. data/node_modules/accessible-autocomplete/CONTRIBUTING.md +161 -0
  17. data/node_modules/accessible-autocomplete/LICENSE.txt +20 -0
  18. data/node_modules/accessible-autocomplete/Procfile +1 -0
  19. data/node_modules/accessible-autocomplete/README.md +490 -0
  20. data/node_modules/accessible-autocomplete/accessibility-criteria.md +42 -0
  21. data/node_modules/accessible-autocomplete/app.json +15 -0
  22. data/node_modules/accessible-autocomplete/babel.config.js +29 -0
  23. data/node_modules/accessible-autocomplete/dist/accessible-autocomplete.min.css +3 -0
  24. data/node_modules/accessible-autocomplete/dist/accessible-autocomplete.min.css.map +1 -0
  25. data/node_modules/accessible-autocomplete/dist/accessible-autocomplete.min.js +2 -0
  26. data/node_modules/accessible-autocomplete/dist/accessible-autocomplete.min.js.map +1 -0
  27. data/node_modules/accessible-autocomplete/dist/lib/accessible-autocomplete.preact.min.js +2 -0
  28. data/node_modules/accessible-autocomplete/dist/lib/accessible-autocomplete.preact.min.js.map +1 -0
  29. data/node_modules/accessible-autocomplete/dist/lib/accessible-autocomplete.react.min.js +2 -0
  30. data/node_modules/accessible-autocomplete/dist/lib/accessible-autocomplete.react.min.js.map +1 -0
  31. data/node_modules/accessible-autocomplete/examples/form-single.html +379 -0
  32. data/node_modules/accessible-autocomplete/examples/form.html +673 -0
  33. data/node_modules/accessible-autocomplete/examples/index.html +738 -0
  34. data/node_modules/accessible-autocomplete/examples/preact/index.html +346 -0
  35. data/node_modules/accessible-autocomplete/examples/react/index.html +347 -0
  36. data/node_modules/accessible-autocomplete/package.json +93 -0
  37. data/node_modules/accessible-autocomplete/postcss.config.js +16 -0
  38. data/node_modules/accessible-autocomplete/preact.js +1 -0
  39. data/node_modules/accessible-autocomplete/react.js +1 -0
  40. data/node_modules/accessible-autocomplete/scripts/check-staged.mjs +16 -0
  41. data/node_modules/accessible-autocomplete/src/autocomplete.css +167 -0
  42. data/node_modules/accessible-autocomplete/src/autocomplete.js +608 -0
  43. data/node_modules/accessible-autocomplete/src/dropdown-arrow-down.js +11 -0
  44. data/node_modules/accessible-autocomplete/src/status.js +125 -0
  45. data/node_modules/accessible-autocomplete/src/wrapper.js +60 -0
  46. data/node_modules/accessible-autocomplete/test/functional/dropdown-arrow-down.js +46 -0
  47. data/node_modules/accessible-autocomplete/test/functional/index.js +793 -0
  48. data/node_modules/accessible-autocomplete/test/functional/wrapper.js +339 -0
  49. data/node_modules/accessible-autocomplete/test/integration/index.js +309 -0
  50. data/node_modules/accessible-autocomplete/test/karma.config.js +46 -0
  51. data/node_modules/accessible-autocomplete/test/wdio.config.js +123 -0
  52. data/node_modules/accessible-autocomplete/webpack.config.mjs +244 -0
  53. metadata +46 -2
@@ -0,0 +1,793 @@
1
+ /* global after, describe, before, beforeEach, it */
2
+
3
+ import { expect } from 'chai'
4
+ import { createElement, render } from 'preact' /** @jsx createElement */
5
+ import Autocomplete from '../../src/autocomplete'
6
+ import Status from '../../src/status'
7
+
8
+ function suggest (query, syncResults) {
9
+ const results = [
10
+ 'France',
11
+ 'Germany',
12
+ 'United Kingdom'
13
+ ]
14
+ syncResults(query
15
+ ? results.filter(function (result) {
16
+ return result.toLowerCase().indexOf(query.toLowerCase()) !== -1
17
+ })
18
+ : []
19
+ )
20
+ }
21
+
22
+ describe('Autocomplete', () => {
23
+ describe('rendering', () => {
24
+ let scratch
25
+
26
+ before(() => {
27
+ scratch = document.createElement('div');
28
+ (document.body || document.documentElement).appendChild(scratch)
29
+ })
30
+
31
+ beforeEach(() => {
32
+ scratch.innerHTML = ''
33
+ })
34
+
35
+ after(() => {
36
+ scratch.parentNode.removeChild(scratch)
37
+ scratch = null
38
+ })
39
+
40
+ describe('basic usage', () => {
41
+ it('renders an input', () => {
42
+ render(<Autocomplete />, scratch)
43
+
44
+ expect(scratch.innerHTML).to.contain('input')
45
+ expect(scratch.innerHTML).to.contain('class="autocomplete__input')
46
+ expect(scratch.innerHTML).to.contain('class="autocomplete__menu')
47
+ expect(scratch.innerHTML).to.contain('name="input-autocomplete"')
48
+ })
49
+
50
+ it('renders an input with a required attribute', () => {
51
+ render(<Autocomplete required />, scratch)
52
+
53
+ expect(scratch.innerHTML).to.contain('required')
54
+ })
55
+
56
+ it('renders an input without a required attribute', () => {
57
+ render(<Autocomplete required={false} />, scratch)
58
+
59
+ expect(scratch.innerHTML).to.not.contain('required')
60
+ })
61
+
62
+ it('renders an input with a name attribute', () => {
63
+ render(<Autocomplete name='bob' />, scratch)
64
+
65
+ expect(scratch.innerHTML).to.contain('name="bob"')
66
+ })
67
+
68
+ it('renders an input with a custom CSS namespace', () => {
69
+ render(<Autocomplete cssNamespace='bob' />, scratch)
70
+
71
+ expect(scratch.innerHTML).to.contain('class="bob__input')
72
+ expect(scratch.innerHTML).to.contain('class="bob__menu')
73
+ })
74
+
75
+ it('renders with an aria-expanded attribute', () => {
76
+ render(<Autocomplete required />, scratch)
77
+
78
+ const wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0]
79
+ const inputElement = wrapperElement.getElementsByTagName('input')[0]
80
+
81
+ expect(inputElement.getAttribute('aria-expanded')).to.equal('false')
82
+ })
83
+
84
+ it('renders with an aria-describedby attribute', () => {
85
+ render(<Autocomplete id='autocomplete-default' />, scratch)
86
+
87
+ const wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0]
88
+ const inputElement = wrapperElement.getElementsByTagName('input')[0]
89
+
90
+ expect(inputElement.getAttribute('aria-describedby')).to.equal('autocomplete-default__assistiveHint')
91
+ })
92
+
93
+ describe('renders with an aria-autocomplete attribute', () => {
94
+ it('of value "list", when autoselect is not enabled', () => {
95
+ render(<Autocomplete required />, scratch)
96
+
97
+ const wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0]
98
+ const inputElement = wrapperElement.getElementsByTagName('input')[0]
99
+
100
+ expect(inputElement.getAttribute('aria-autocomplete')).to.equal('list')
101
+ })
102
+
103
+ it('of value "both", when autoselect is enabled', () => {
104
+ render(<Autocomplete required autoselect />, scratch)
105
+
106
+ const wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0]
107
+ const inputElement = wrapperElement.getElementsByTagName('input')[0]
108
+
109
+ expect(inputElement.getAttribute('aria-autocomplete')).to.equal('both')
110
+ })
111
+ })
112
+
113
+ describe('menuAttributes', () => {
114
+ it('renders with the extra attribute on the menu', () => {
115
+ render(<Autocomplete menuAttributes={{ 'aria-labelledby': 'autocomplete-default' }} id='autocomplete-default' />, scratch)
116
+
117
+ const wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0]
118
+ const dropdownElement = wrapperElement.getElementsByTagName('ul')[0]
119
+ const inputElement = wrapperElement.getElementsByTagName('input')[0]
120
+
121
+ expect(dropdownElement.getAttribute('aria-labelledby')).to.equal('autocomplete-default')
122
+ expect(inputElement.getAttribute('id')).to.equal('autocomplete-default')
123
+ })
124
+
125
+ describe('attributes computed by the component', () => {
126
+ it('does not override attributes computed by the component', () => {
127
+ const menuAttributes = {
128
+ id: 'custom-id',
129
+ role: 'custom-role'
130
+ }
131
+
132
+ render(<Autocomplete menuAttributes={menuAttributes} id='autocomplete-default' />, scratch)
133
+
134
+ // Check that the computed values are the ones expected in the HTML
135
+ const menuElement = scratch.getElementsByClassName('autocomplete__menu')[0]
136
+ expect(menuElement.id).to.equal('autocomplete-default__listbox', 'HTML id')
137
+ expect(menuElement.role).to.equal('listbox', 'HTML role')
138
+
139
+ // Check that in protecting the menu, we don't affect the object passed as option
140
+ expect(menuAttributes.id).to.equal('custom-id', 'options id')
141
+ expect(menuAttributes.role).to.equal('custom-role', 'options role')
142
+ })
143
+ })
144
+
145
+ it('adds `className` to the computed value of the `class` attribute', () => {
146
+ const menuAttributes = {
147
+ className: 'custom-className'
148
+ }
149
+
150
+ render(<Autocomplete menuAttributes={menuAttributes} id='autocomplete-default' />, scratch)
151
+
152
+ // Check that the computed values are the ones expected in the HTML
153
+ const menuElement = scratch.getElementsByClassName('autocomplete__menu')[0]
154
+ expect(menuElement.getAttribute('class')).to.equal('autocomplete__menu autocomplete__menu--inline autocomplete__menu--hidden custom-className')
155
+
156
+ // Check that in protecting the menu, we don't affect the object passed as option
157
+ expect(menuAttributes.className).to.equal('custom-className')
158
+ })
159
+
160
+ // Align with Preact's behaviour where `class` takes precedence
161
+ it('adds `class` to the computed value of the `class` attribute, ignoring `className` if present', () => {
162
+ const menuAttributes = {
163
+ className: 'custom-className',
164
+ class: 'custom-class'
165
+ }
166
+
167
+ render(<Autocomplete menuAttributes={menuAttributes} id='autocomplete-default' />, scratch)
168
+
169
+ // Check that the computed values are the ones expected in the HTML
170
+ const menuElement = scratch.getElementsByClassName('autocomplete__menu')[0]
171
+ expect(menuElement.getAttribute('class')).to.equal('autocomplete__menu autocomplete__menu--inline autocomplete__menu--hidden custom-class')
172
+
173
+ // Check that in protecting the menu, we don't affect the object passed as option
174
+ expect(menuAttributes.className).to.equal('custom-className')
175
+ expect(menuAttributes.class).to.equal('custom-class')
176
+ })
177
+ })
178
+
179
+ it('renders with extra class on the input', () => {
180
+ render(<Autocomplete inputClasses='custom-class' id='autocomplete-default' />, scratch)
181
+
182
+ const inputElement = scratch.getElementsByClassName('autocomplete__input')[0]
183
+
184
+ expect(inputElement.getAttribute('class')).to.contain(' custom-class')
185
+ })
186
+
187
+ it('renders with extra class on the menu', () => {
188
+ render(<Autocomplete menuClasses='custom-class' id='autocomplete-default' />, scratch)
189
+
190
+ const menuElement = scratch.getElementsByClassName('autocomplete__menu')[0]
191
+
192
+ expect(menuElement.getAttribute('class')).to.contain('custom-class')
193
+ })
194
+
195
+ it('renders with the correct roles', () => {
196
+ render(<Autocomplete required />, scratch)
197
+
198
+ const wrapperElement = scratch.getElementsByClassName('autocomplete__wrapper')[0]
199
+ const inputElement = wrapperElement.getElementsByTagName('input')[0]
200
+ const dropdownElement = wrapperElement.getElementsByTagName('ul')[0]
201
+
202
+ expect(inputElement.getAttribute('role')).to.equal('combobox', 'input should have combobox role')
203
+ expect(dropdownElement.getAttribute('role')).to.equal('listbox', 'menu should have listbox role')
204
+ })
205
+ })
206
+ })
207
+
208
+ describe('behaviour', () => {
209
+ let autocomplete, autoselectAutocomplete, onConfirmAutocomplete, onConfirmTriggered,
210
+ autoselectOnSelectAutocomplete, confirmOnBlurAutocomplete
211
+
212
+ beforeEach(() => {
213
+ autocomplete = new Autocomplete({
214
+ ...Autocomplete.defaultProps,
215
+ id: 'test',
216
+ source: suggest
217
+ })
218
+
219
+ autoselectAutocomplete = new Autocomplete({
220
+ ...Autocomplete.defaultProps,
221
+ autoselect: true,
222
+ id: 'test2',
223
+ source: suggest
224
+ })
225
+
226
+ onConfirmTriggered = false
227
+ onConfirmAutocomplete = new Autocomplete({
228
+ ...Autocomplete.defaultProps,
229
+ id: 'test3',
230
+ onConfirm: () => { onConfirmTriggered = true },
231
+ source: suggest
232
+ })
233
+
234
+ autoselectOnSelectAutocomplete = new Autocomplete({
235
+ ...Autocomplete.defaultProps,
236
+ autoselect: true,
237
+ id: 'test4',
238
+ onConfirm: () => { onConfirmTriggered = true },
239
+ source: suggest
240
+ })
241
+
242
+ confirmOnBlurAutocomplete = new Autocomplete({
243
+ ...Autocomplete.defaultProps,
244
+ id: 'test5',
245
+ onConfirm: () => { onConfirmTriggered = true },
246
+ confirmOnBlur: false,
247
+ source: suggest
248
+ })
249
+ })
250
+
251
+ describe('typing', () => {
252
+ it('searches for options', () => {
253
+ autocomplete.handleInputChange({ target: { value: 'f' } })
254
+ expect(autocomplete.state.menuOpen).to.equal(true)
255
+ expect(autocomplete.state.options).to.contain('France')
256
+ })
257
+
258
+ it('hides menu when no options are available', () => {
259
+ autocomplete.handleInputChange({ target: { value: 'aa' } })
260
+ expect(autocomplete.state.menuOpen).to.equal(false)
261
+ expect(autocomplete.state.options.length).to.equal(0)
262
+ })
263
+
264
+ it('hides menu when query becomes empty', () => {
265
+ autocomplete.setState({ query: 'f', options: ['France'], menuOpen: true })
266
+ autocomplete.handleInputChange({ target: { value: '' } })
267
+ expect(autocomplete.state.menuOpen).to.equal(false)
268
+ })
269
+
270
+ it('searches with the new term when query length changes', () => {
271
+ autocomplete.setState({ query: 'fr', options: ['France'] })
272
+ autocomplete.handleInputChange({ target: { value: 'fb' } })
273
+ expect(autocomplete.state.options.length).to.equal(0)
274
+ })
275
+
276
+ it('removes the aria-describedby attribute when query is non empty', () => {
277
+ expect(autocomplete.state.ariaHint).to.equal(true)
278
+ autocomplete.handleInputChange({ target: { value: 'a' } })
279
+ expect(autocomplete.state.ariaHint).to.equal(false)
280
+ autocomplete.handleInputChange({ target: { value: '' } })
281
+ expect(autocomplete.state.ariaHint).to.equal(true)
282
+ })
283
+
284
+ describe('with minLength', () => {
285
+ beforeEach(() => {
286
+ autocomplete = new Autocomplete({
287
+ ...Autocomplete.defaultProps,
288
+ id: 'test',
289
+ source: suggest,
290
+ minLength: 2
291
+ })
292
+ })
293
+
294
+ it('doesn\'t search when under limit', () => {
295
+ autocomplete.handleInputChange({ target: { value: 'f' } })
296
+ expect(autocomplete.state.menuOpen).to.equal(false)
297
+ expect(autocomplete.state.options.length).to.equal(0)
298
+ })
299
+
300
+ it('does search when over limit', () => {
301
+ autocomplete.handleInputChange({ target: { value: 'fra' } })
302
+ expect(autocomplete.state.menuOpen).to.equal(true)
303
+ expect(autocomplete.state.options).to.contain('France')
304
+ })
305
+
306
+ it('hides results when going under limit', () => {
307
+ autocomplete.setState({ menuOpen: true, query: 'fr', options: ['France'] })
308
+ autocomplete.handleInputChange({ target: { value: 'f' } })
309
+ expect(autocomplete.state.menuOpen).to.equal(false)
310
+ expect(autocomplete.state.options.length).to.equal(0)
311
+ })
312
+ })
313
+ })
314
+
315
+ describe('focusing input', () => {
316
+ describe('when no query is present', () => {
317
+ it('does not display menu', () => {
318
+ autocomplete.setState({ query: '' })
319
+ autocomplete.handleInputFocus()
320
+ expect(autocomplete.state.menuOpen).to.equal(false)
321
+ expect(autocomplete.state.focused).to.equal(-1)
322
+ })
323
+ })
324
+
325
+ describe('when a non-matched query is present (no matching options are present)', () => {
326
+ it('does not display menu', () => {
327
+ autocomplete.setState({ query: 'f' })
328
+ autocomplete.handleInputFocus()
329
+ expect(autocomplete.state.menuOpen).to.equal(false)
330
+ expect(autocomplete.state.focused).to.equal(-1)
331
+ })
332
+ })
333
+
334
+ describe('when a matched query is present (matching options exist)', () => {
335
+ describe('and no user choice has yet been made', () => {
336
+ it('displays menu', () => {
337
+ autocomplete.setState({
338
+ menuOpen: false,
339
+ options: ['France'],
340
+ query: 'fr',
341
+ focused: null,
342
+ selected: null,
343
+ validChoiceMade: false
344
+ })
345
+ autocomplete.handleInputFocus()
346
+ expect(autocomplete.state.focused).to.equal(-1)
347
+ expect(autocomplete.state.menuOpen).to.equal(true)
348
+ expect(autocomplete.state.selected).to.equal(-1)
349
+ })
350
+ })
351
+ describe('and a user choice HAS been made', () => {
352
+ it('does not display menu', () => {
353
+ autocomplete.setState({
354
+ menuOpen: false,
355
+ options: ['France'],
356
+ query: 'fr',
357
+ focused: null,
358
+ selected: null,
359
+ validChoiceMade: true
360
+ })
361
+ autocomplete.handleInputFocus()
362
+ expect(autocomplete.state.focused).to.equal(-1)
363
+ expect(autocomplete.state.menuOpen).to.equal(false)
364
+ })
365
+ })
366
+ })
367
+
368
+ describe('with option selected', () => {
369
+ it('leaves menu open, does not change query', () => {
370
+ autocomplete.setState({ menuOpen: true, options: ['France'], query: 'fr', focused: 0, selected: 0 })
371
+ autocomplete.handleInputFocus()
372
+ expect(autocomplete.state.focused).to.equal(-1)
373
+ expect(autocomplete.state.menuOpen).to.equal(true)
374
+ expect(autocomplete.state.query).to.equal('fr')
375
+ })
376
+ })
377
+
378
+ describe('with defaultValue', () => {
379
+ beforeEach(() => {
380
+ autocomplete = new Autocomplete({
381
+ ...Autocomplete.defaultProps,
382
+ defaultValue: 'France',
383
+ id: 'test',
384
+ source: suggest
385
+ })
386
+ })
387
+
388
+ it('is prefilled', () => {
389
+ expect(autocomplete.state.options.length).to.equal(1)
390
+ expect(autocomplete.state.options[0]).to.equal('France')
391
+ expect(autocomplete.state.query).to.equal('France')
392
+ })
393
+ })
394
+ })
395
+
396
+ describe('blurring input', () => {
397
+ it('unfocuses component', () => {
398
+ autocomplete.setState({ menuOpen: true, options: ['France'], query: 'fr', focused: -1, selected: -1 })
399
+ autocomplete.handleInputBlur({ relatedTarget: null })
400
+ expect(autocomplete.state.focused).to.equal(null)
401
+ expect(autocomplete.state.menuOpen).to.equal(false)
402
+ expect(autocomplete.state.query).to.equal('fr')
403
+ })
404
+
405
+ describe('with autoselect and onConfirm', () => {
406
+ it('unfocuses component, updates query, triggers onConfirm', () => {
407
+ autoselectOnSelectAutocomplete.setState({ menuOpen: true, options: ['France'], query: 'fr', focused: -1, selected: 0 })
408
+ autoselectOnSelectAutocomplete.handleInputBlur({ target: 'mock', relatedTarget: 'relatedMock' }, 0)
409
+ expect(autoselectOnSelectAutocomplete.state.focused).to.equal(null)
410
+ expect(autoselectOnSelectAutocomplete.state.menuOpen).to.equal(false)
411
+ expect(autoselectOnSelectAutocomplete.state.query).to.equal('France')
412
+ expect(onConfirmTriggered).to.equal(true)
413
+ })
414
+ })
415
+
416
+ describe('with confirmOnBlur false', () => {
417
+ it('unfocuses component, does not touch query, does not trigger onConfirm', () => {
418
+ confirmOnBlurAutocomplete.setState({ menuOpen: true, options: ['France'], query: 'fr', focused: -1, selected: 0 })
419
+ confirmOnBlurAutocomplete.handleInputBlur({ target: 'mock', relatedTarget: 'relatedMock' }, 0)
420
+ expect(confirmOnBlurAutocomplete.state.focused).to.equal(null)
421
+ expect(confirmOnBlurAutocomplete.state.menuOpen).to.equal(false)
422
+ expect(confirmOnBlurAutocomplete.state.query).to.equal('fr')
423
+ expect(onConfirmTriggered).to.equal(false)
424
+ })
425
+ })
426
+ })
427
+
428
+ describe('focusing option', () => {
429
+ it('sets the option as focused', () => {
430
+ autocomplete.setState({ options: ['France'] })
431
+ autocomplete.handleOptionFocus(0)
432
+ expect(autocomplete.state.focused).to.equal(0)
433
+ })
434
+ })
435
+
436
+ describe('focusing out option', () => {
437
+ describe('with input selected', () => {
438
+ it('unfocuses component, does not change query', () => {
439
+ autocomplete.setState({ menuOpen: true, options: ['France'], query: 'fr', focused: 0, selected: -1 })
440
+ autocomplete.handleOptionBlur({ target: 'mock', relatedTarget: 'relatedMock' }, 0)
441
+ expect(autocomplete.state.focused).to.equal(null)
442
+ expect(autocomplete.state.menuOpen).to.equal(false)
443
+ expect(autocomplete.state.query).to.equal('fr')
444
+ })
445
+ })
446
+
447
+ describe('with option selected', () => {
448
+ describe('with confirmOnBlur true', () => {
449
+ it('unfocuses component, updates query', () => {
450
+ autocomplete.setState({ menuOpen: true, options: ['France'], query: 'fr', focused: 0, selected: 0 })
451
+ autocomplete.handleOptionBlur({ target: 'mock', relatedTarget: 'relatedMock' }, 0)
452
+ expect(autocomplete.state.focused).to.equal(null)
453
+ expect(autocomplete.state.menuOpen).to.equal(false)
454
+ expect(autocomplete.state.query).to.equal('France')
455
+ })
456
+ })
457
+ describe('with confirmOnBlur false', () => {
458
+ it('unfocuses component, does not update query', () => {
459
+ confirmOnBlurAutocomplete.setState({ menuOpen: true, options: ['France'], query: 'fr', focused: 0, selected: 0 })
460
+ confirmOnBlurAutocomplete.handleOptionBlur({ target: 'mock', relatedTarget: 'relatedMock' }, 0)
461
+ expect(confirmOnBlurAutocomplete.state.focused).to.equal(null)
462
+ expect(confirmOnBlurAutocomplete.state.menuOpen).to.equal(false)
463
+ expect(confirmOnBlurAutocomplete.state.query).to.equal('fr')
464
+ })
465
+ })
466
+ })
467
+ })
468
+
469
+ describe('hovering option', () => {
470
+ it('sets the option as hovered, does not change focused, does not change selected', () => {
471
+ autocomplete.setState({ options: ['France'], hovered: null, focused: -1, selected: -1 })
472
+ autocomplete.handleOptionMouseEnter({}, 0)
473
+ expect(autocomplete.state.hovered).to.equal(0)
474
+ expect(autocomplete.state.focused).to.equal(-1)
475
+ expect(autocomplete.state.selected).to.equal(-1)
476
+ })
477
+ })
478
+
479
+ describe('hovering out option', () => {
480
+ it('sets focus back on selected, sets hovered to null', () => {
481
+ autocomplete.setState({ options: ['France'], hovered: 0, focused: -1, selected: -1 })
482
+ autocomplete.handleListMouseLeave({ toElement: 'mock' }, 0)
483
+ expect(autocomplete.state.hovered).to.equal(null)
484
+ expect(autocomplete.state.focused).to.equal(-1)
485
+ expect(autocomplete.state.selected).to.equal(-1)
486
+ })
487
+ })
488
+
489
+ describe('up key', () => {
490
+ it('focuses previous element', () => {
491
+ autocomplete.setState({ menuOpen: true, options: ['France'], focused: 0 })
492
+ autocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 38 })
493
+ expect(autocomplete.state.focused).to.equal(-1)
494
+ })
495
+ })
496
+
497
+ describe('down key', () => {
498
+ describe('0 options available', () => {
499
+ it('does nothing', () => {
500
+ autocomplete.setState({ menuOpen: false, options: [], focused: -1 })
501
+ const stateBefore = autocomplete.state
502
+ autocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 40 })
503
+ expect(autocomplete.state).to.equal(stateBefore)
504
+ })
505
+ })
506
+
507
+ describe('1 option available', () => {
508
+ it('focuses next element', () => {
509
+ autocomplete.setState({ menuOpen: true, options: ['France'], focused: -1, selected: -1 })
510
+ autocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 40 })
511
+ expect(autocomplete.state.focused).to.equal(0)
512
+ expect(autocomplete.state.selected).to.equal(0)
513
+ })
514
+ })
515
+
516
+ describe('2 or more option available', () => {
517
+ it('focuses next element', () => {
518
+ autocomplete.setState({ menuOpen: true, options: ['France', 'Germany'], focused: 0, selected: 0 })
519
+ autocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 40 })
520
+ expect(autocomplete.state.focused).to.equal(1)
521
+ expect(autocomplete.state.selected).to.equal(1)
522
+ })
523
+ })
524
+
525
+ describe('autoselect', () => {
526
+ describe('0 options available', () => {
527
+ it('does nothing', () => {
528
+ autoselectAutocomplete.setState({ menuOpen: false, options: [], focused: -1, selected: -1 })
529
+ const stateBefore = autoselectAutocomplete.state
530
+ autoselectAutocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 40 })
531
+ expect(autoselectAutocomplete.state).to.equal(stateBefore)
532
+ })
533
+ })
534
+
535
+ describe('1 option available', () => {
536
+ it('does nothing', () => {
537
+ autoselectAutocomplete.setState({ menuOpen: true, options: ['France'], focused: -1, selected: 0 })
538
+ const stateBefore = autoselectAutocomplete.state
539
+ autoselectAutocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 40 })
540
+ expect(autoselectAutocomplete.state).to.equal(stateBefore)
541
+ })
542
+ })
543
+
544
+ describe('2 or more option available', () => {
545
+ it('on input, focuses second element', () => {
546
+ autoselectAutocomplete.setState({ menuOpen: true, options: ['France', 'Germany'], focused: -1, selected: 0 })
547
+ autoselectAutocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 40 })
548
+ expect(autoselectAutocomplete.state.focused).to.equal(1)
549
+ expect(autoselectAutocomplete.state.selected).to.equal(1)
550
+ })
551
+ })
552
+ })
553
+ })
554
+
555
+ describe('escape key', () => {
556
+ it('unfocuses component', () => {
557
+ autocomplete.setState({ menuOpen: true, options: ['France'], focused: -1 })
558
+ autocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 27 })
559
+ expect(autocomplete.state.menuOpen).to.equal(false)
560
+ expect(autocomplete.state.focused).to.equal(null)
561
+ })
562
+ })
563
+
564
+ describe('enter key', () => {
565
+ describe('on an option', () => {
566
+ it('prevents default, closes the menu, sets the query, focuses the input, triggers onConfirm', () => {
567
+ let preventedDefault = false
568
+ onConfirmAutocomplete.setState({ menuOpen: true, options: ['France'], focused: 0, selected: 0 })
569
+ onConfirmAutocomplete.handleKeyDown({ preventDefault: () => { preventedDefault = true }, keyCode: 13 })
570
+ expect(onConfirmAutocomplete.state.menuOpen).to.equal(false)
571
+ expect(onConfirmAutocomplete.state.query).to.equal('France')
572
+ expect(onConfirmAutocomplete.state.focused).to.equal(-1)
573
+ expect(onConfirmAutocomplete.state.selected).to.equal(-1)
574
+ expect(preventedDefault).to.equal(true)
575
+ expect(onConfirmTriggered).to.equal(true)
576
+ })
577
+ })
578
+
579
+ describe('on the input', () => {
580
+ describe('with menu opened', () => {
581
+ it('prevents default, does nothing', () => {
582
+ let preventedDefault = false
583
+ autocomplete.setState({ menuOpen: true, options: [], query: 'asd', focused: -1, selected: -1 })
584
+ const stateBefore = autocomplete.state
585
+ autocomplete.handleKeyDown({ preventDefault: () => { preventedDefault = true }, keyCode: 13 })
586
+ expect(autocomplete.state).to.equal(stateBefore)
587
+ expect(preventedDefault).to.equal(true)
588
+ })
589
+ })
590
+
591
+ describe('with menu closed', () => {
592
+ it('bubbles, does not prevent default', () => {
593
+ let preventedDefault = false
594
+ autocomplete.setState({ menuOpen: false, options: ['France'], focused: -1, selected: -1 })
595
+ const stateBefore = autocomplete.state
596
+ autocomplete.handleKeyDown({ preventDefault: () => { preventedDefault = true }, keyCode: 13 })
597
+ expect(autocomplete.state).to.equal(stateBefore)
598
+ expect(preventedDefault).to.equal(false)
599
+ })
600
+ })
601
+
602
+ describe('autoselect', () => {
603
+ it('closes the menu, selects the first option, keeps input focused', () => {
604
+ autoselectAutocomplete.setState({ menuOpen: true, options: ['France'], focused: -1, selected: 0 })
605
+ autoselectAutocomplete.handleKeyDown({ preventDefault: () => {}, keyCode: 13 })
606
+ expect(autoselectAutocomplete.state.menuOpen).to.equal(false)
607
+ expect(autoselectAutocomplete.state.query).to.equal('France')
608
+ expect(autoselectAutocomplete.state.focused).to.equal(-1)
609
+ expect(autoselectAutocomplete.state.selected).to.equal(-1)
610
+ })
611
+ })
612
+ })
613
+ })
614
+
615
+ describe('space key', () => {
616
+ describe('on an option', () => {
617
+ it('prevents default, closes the menu, sets the query, focuses the input, triggers onConfirm', () => {
618
+ let preventedDefault = false
619
+ onConfirmAutocomplete.setState({ menuOpen: true, options: ['France'], focused: 0, selected: 0 })
620
+ onConfirmAutocomplete.handleKeyDown({ preventDefault: () => { preventedDefault = true }, keyCode: 32 })
621
+ expect(onConfirmAutocomplete.state.menuOpen).to.equal(false)
622
+ expect(onConfirmAutocomplete.state.query).to.equal('France')
623
+ expect(onConfirmAutocomplete.state.focused).to.equal(-1)
624
+ expect(onConfirmAutocomplete.state.selected).to.equal(-1)
625
+ expect(preventedDefault).to.equal(true)
626
+ expect(onConfirmTriggered).to.equal(true)
627
+ })
628
+ })
629
+ })
630
+
631
+ describe('an unrecognised key', () => {
632
+ it('does nothing', () => {
633
+ autocomplete.setState({ menuOpen: true, options: ['France'], focused: 0, selected: 0 })
634
+ autocomplete.elementReferences[-1] = 'input element'
635
+ autocomplete.handleKeyDown({ target: 'not the input element', keyCode: 4242 })
636
+ expect(autocomplete.state.focused).to.equal(0)
637
+ expect(autocomplete.state.selected).to.equal(0)
638
+ })
639
+ })
640
+
641
+ describe('derived state', () => {
642
+ it('initially assumes no valid choice on each new input', () => {
643
+ autocomplete.handleInputChange({ target: { value: 'F' } })
644
+ expect(autocomplete.state.validChoiceMade).to.equal(false)
645
+ })
646
+
647
+ describe('identifies that the user has made a valid choice', () => {
648
+ it('when an option is actively clicked', () => {
649
+ autocomplete.setState({ query: 'f', options: ['France'], validChoiceMade: false })
650
+ autocomplete.handleOptionClick({}, 0)
651
+ expect(autocomplete.state.validChoiceMade).to.equal(true)
652
+ })
653
+
654
+ it('when the input is blurred, autoselect is disabled, and the current query exactly matches an option', () => {
655
+ autocomplete.setState({ query: 'France', options: ['France'], validChoiceMade: false })
656
+ autocomplete.handleComponentBlur({})
657
+ expect(autocomplete.state.validChoiceMade).to.equal(true)
658
+ })
659
+
660
+ it('when in the same scenario, but the match differs only by case sensitivity', () => {
661
+ autocomplete.setState({ query: 'fraNCe', options: ['France'], validChoiceMade: false })
662
+ autocomplete.handleComponentBlur({})
663
+ expect(autocomplete.state.validChoiceMade).to.equal(true)
664
+ })
665
+
666
+ it('when the input is blurred, autoselect is enabled, and the current query results in at least one option', () => {
667
+ autoselectAutocomplete.setState({ options: ['France'], validChoiceMade: false })
668
+ autoselectAutocomplete.handleInputChange({ target: { value: 'France' } })
669
+ autoselectAutocomplete.handleComponentBlur({})
670
+ expect(autoselectAutocomplete.state.validChoiceMade).to.equal(true)
671
+ })
672
+ })
673
+
674
+ describe('identifies that the user has not made a valid choice', () => {
675
+ it('when the input is blurred, autoselect is disabled, and the current query does not match an option', () => {
676
+ autocomplete.setState({ query: 'Fracne', options: ['France'], validChoiceMade: false })
677
+ autocomplete.handleComponentBlur({})
678
+ expect(autocomplete.state.validChoiceMade).to.equal(false)
679
+ })
680
+
681
+ it('when the input is blurred, autoselect is enabled, but no options exist for the current query', () => {
682
+ autoselectAutocomplete.setState({ options: [], validChoiceMade: false })
683
+ autoselectAutocomplete.handleInputChange({ target: { value: 'gpvx' } })
684
+ autoselectAutocomplete.handleComponentBlur({})
685
+ expect(autoselectAutocomplete.state.validChoiceMade).to.equal(false)
686
+ })
687
+ })
688
+
689
+ describe('identifies that the valid choice situation has changed', () => {
690
+ it('when the user amends a previously matched query such that it no longer matches an option', () => {
691
+ autocomplete.setState({ query: 'France', options: ['France'], validChoiceMade: false })
692
+ autocomplete.handleComponentBlur({})
693
+ expect(autocomplete.state.validChoiceMade).to.equal(true)
694
+ autocomplete.handleInputChange({ target: { value: 'Francey' } })
695
+ autocomplete.handleComponentBlur({})
696
+ expect(autocomplete.state.validChoiceMade).to.equal(false)
697
+ autocomplete.handleInputChange({ target: { value: 'France' } })
698
+ autocomplete.handleComponentBlur({})
699
+ expect(autocomplete.state.validChoiceMade).to.equal(true)
700
+ autocomplete.handleInputChange({ target: { value: 'Franc' } })
701
+ autocomplete.handleComponentBlur({})
702
+ expect(autocomplete.state.validChoiceMade).to.equal(false)
703
+ })
704
+ })
705
+ })
706
+ })
707
+ })
708
+
709
+ describe('Status', () => {
710
+ describe('rendering', () => {
711
+ let scratch
712
+
713
+ before(() => {
714
+ scratch = document.createElement('div');
715
+ (document.body || document.documentElement).appendChild(scratch)
716
+ })
717
+
718
+ beforeEach(() => {
719
+ scratch.innerHTML = ''
720
+ })
721
+
722
+ after(() => {
723
+ scratch.parentNode.removeChild(scratch)
724
+ scratch = null
725
+ })
726
+
727
+ it('renders a pair of aria live regions', () => {
728
+ render(<Status />, scratch)
729
+ expect(scratch.innerHTML).to.contain('div')
730
+
731
+ const wrapperElement = scratch.getElementsByTagName('div')[0]
732
+ const ariaLiveA = wrapperElement.getElementsByTagName('div')[0]
733
+ const ariaLiveB = wrapperElement.getElementsByTagName('div')[1]
734
+
735
+ expect(ariaLiveA.getAttribute('role')).to.equal('status', 'first aria live region should be marked as role=status')
736
+ expect(ariaLiveA.getAttribute('aria-atomic')).to.equal('true', 'first aria live region should be marked as atomic')
737
+ expect(ariaLiveA.getAttribute('aria-live')).to.equal('polite', 'first aria live region should be marked as polite')
738
+ expect(ariaLiveB.getAttribute('role')).to.equal('status', 'second aria live region should be marked as role=status')
739
+ expect(ariaLiveB.getAttribute('aria-atomic')).to.equal('true', 'second aria live region should be marked as atomic')
740
+ expect(ariaLiveB.getAttribute('aria-live')).to.equal('polite', 'second aria live region should be marked as polite')
741
+ })
742
+
743
+ describe('behaviour', () => {
744
+ describe('silences aria live announcement', () => {
745
+ it('when a valid choice has been made and the input has focus', (done) => {
746
+ const status = new Status({
747
+ ...Status.defaultProps,
748
+ validChoiceMade: true,
749
+ isInFocus: true
750
+ })
751
+ status.componentWillMount()
752
+ status.render()
753
+
754
+ setTimeout(() => {
755
+ expect(status.state.silenced).to.equal(true)
756
+ done()
757
+ }, 1500)
758
+ })
759
+
760
+ it('when the input no longer has focus', (done) => {
761
+ const status = new Status({
762
+ ...Status.defaultProps,
763
+ validChoiceMade: false,
764
+ isInFocus: false
765
+ })
766
+ status.componentWillMount()
767
+ status.render()
768
+
769
+ setTimeout(() => {
770
+ expect(status.state.silenced).to.equal(true)
771
+ done()
772
+ }, 1500)
773
+ })
774
+ })
775
+ describe('does not silence aria live announcement', () => {
776
+ it('when a valid choice has not been made and the input has focus', (done) => {
777
+ const status = new Status({
778
+ ...Status.defaultProps,
779
+ validChoiceMade: false,
780
+ isInFocus: true
781
+ })
782
+ status.componentWillMount()
783
+ status.render()
784
+
785
+ setTimeout(() => {
786
+ expect(status.state.silenced).to.equal(false)
787
+ done()
788
+ }, 1500)
789
+ })
790
+ })
791
+ })
792
+ })
793
+ })