govuk_publishing_components 43.3.0 → 43.4.1

Sign up to get free protection for your applications and to get access to all the features.
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
+ })