govuk_publishing_components 43.4.0 → 43.5.0

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