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.
- checksums.yaml +4 -4
- data/app/assets/images/govuk_publishing_components/icon-autocomplete-search-suggestion.svg +4 -0
- data/app/assets/javascripts/govuk_publishing_components/components/search-with-autocomplete.js +123 -0
- data/app/assets/stylesheets/govuk_publishing_components/components/_layout-super-navigation-header.scss +5 -4
- data/app/assets/stylesheets/govuk_publishing_components/components/_phase-banner.scss +0 -2
- data/app/assets/stylesheets/govuk_publishing_components/components/_search-with-autocomplete.scss +200 -0
- data/app/views/govuk_publishing_components/components/_layout_header.html.erb +16 -40
- data/app/views/govuk_publishing_components/components/_search.html.erb +16 -14
- data/app/views/govuk_publishing_components/components/_search_with_autocomplete.html.erb +27 -0
- data/app/views/govuk_publishing_components/components/docs/layout_header.yml +0 -41
- data/app/views/govuk_publishing_components/components/docs/phase_banner.yml +4 -0
- data/app/views/govuk_publishing_components/components/docs/search_with_autocomplete.yml +61 -0
- data/lib/govuk_publishing_components/version.rb +1 -1
- data/node_modules/accessible-autocomplete/CHANGELOG.md +390 -0
- data/node_modules/accessible-autocomplete/CODEOWNERS +2 -0
- data/node_modules/accessible-autocomplete/CONTRIBUTING.md +161 -0
- data/node_modules/accessible-autocomplete/LICENSE.txt +20 -0
- data/node_modules/accessible-autocomplete/Procfile +1 -0
- data/node_modules/accessible-autocomplete/README.md +490 -0
- data/node_modules/accessible-autocomplete/accessibility-criteria.md +42 -0
- data/node_modules/accessible-autocomplete/app.json +15 -0
- data/node_modules/accessible-autocomplete/babel.config.js +29 -0
- data/node_modules/accessible-autocomplete/dist/accessible-autocomplete.min.css +3 -0
- data/node_modules/accessible-autocomplete/dist/accessible-autocomplete.min.css.map +1 -0
- data/node_modules/accessible-autocomplete/dist/accessible-autocomplete.min.js +2 -0
- data/node_modules/accessible-autocomplete/dist/accessible-autocomplete.min.js.map +1 -0
- data/node_modules/accessible-autocomplete/dist/lib/accessible-autocomplete.preact.min.js +2 -0
- data/node_modules/accessible-autocomplete/dist/lib/accessible-autocomplete.preact.min.js.map +1 -0
- data/node_modules/accessible-autocomplete/dist/lib/accessible-autocomplete.react.min.js +2 -0
- data/node_modules/accessible-autocomplete/dist/lib/accessible-autocomplete.react.min.js.map +1 -0
- data/node_modules/accessible-autocomplete/examples/form-single.html +379 -0
- data/node_modules/accessible-autocomplete/examples/form.html +673 -0
- data/node_modules/accessible-autocomplete/examples/index.html +738 -0
- data/node_modules/accessible-autocomplete/examples/preact/index.html +346 -0
- data/node_modules/accessible-autocomplete/examples/react/index.html +347 -0
- data/node_modules/accessible-autocomplete/package.json +93 -0
- data/node_modules/accessible-autocomplete/postcss.config.js +16 -0
- data/node_modules/accessible-autocomplete/preact.js +1 -0
- data/node_modules/accessible-autocomplete/react.js +1 -0
- data/node_modules/accessible-autocomplete/scripts/check-staged.mjs +16 -0
- data/node_modules/accessible-autocomplete/src/autocomplete.css +167 -0
- data/node_modules/accessible-autocomplete/src/autocomplete.js +608 -0
- data/node_modules/accessible-autocomplete/src/dropdown-arrow-down.js +11 -0
- data/node_modules/accessible-autocomplete/src/status.js +125 -0
- data/node_modules/accessible-autocomplete/src/wrapper.js +60 -0
- data/node_modules/accessible-autocomplete/test/functional/dropdown-arrow-down.js +46 -0
- data/node_modules/accessible-autocomplete/test/functional/index.js +793 -0
- data/node_modules/accessible-autocomplete/test/functional/wrapper.js +339 -0
- data/node_modules/accessible-autocomplete/test/integration/index.js +309 -0
- data/node_modules/accessible-autocomplete/test/karma.config.js +46 -0
- data/node_modules/accessible-autocomplete/test/wdio.config.js +123 -0
- data/node_modules/accessible-autocomplete/webpack.config.mjs +244 -0
- metadata +46 -2
@@ -0,0 +1,608 @@
|
|
1
|
+
import { createElement, Component } from 'preact' /** @jsx createElement */
|
2
|
+
import Status from './status'
|
3
|
+
import DropdownArrowDown from './dropdown-arrow-down'
|
4
|
+
|
5
|
+
const IS_PREACT = process.env.COMPONENT_LIBRARY === 'PREACT'
|
6
|
+
const IS_REACT = process.env.COMPONENT_LIBRARY === 'REACT'
|
7
|
+
|
8
|
+
const keyCodes = {
|
9
|
+
13: 'enter',
|
10
|
+
27: 'escape',
|
11
|
+
32: 'space',
|
12
|
+
38: 'up',
|
13
|
+
40: 'down'
|
14
|
+
}
|
15
|
+
|
16
|
+
function isIosDevice () {
|
17
|
+
return typeof navigator !== 'undefined' && !!(navigator.userAgent.match(/(iPod|iPhone|iPad)/g) && navigator.userAgent.match(/AppleWebKit/g))
|
18
|
+
}
|
19
|
+
|
20
|
+
function isPrintableKeyCode (keyCode) {
|
21
|
+
return (
|
22
|
+
(keyCode > 47 && keyCode < 58) || // number keys
|
23
|
+
keyCode === 32 || keyCode === 8 || // spacebar or backspace
|
24
|
+
(keyCode > 64 && keyCode < 91) || // letter keys
|
25
|
+
(keyCode > 95 && keyCode < 112) || // numpad keys
|
26
|
+
(keyCode > 185 && keyCode < 193) || // ;=,-./` (in order)
|
27
|
+
(keyCode > 218 && keyCode < 223) // [\]' (in order)
|
28
|
+
)
|
29
|
+
}
|
30
|
+
|
31
|
+
// Preact does not implement onChange on inputs, but React does.
|
32
|
+
function onChangeCrossLibrary (handler) {
|
33
|
+
if (IS_PREACT) { return { onInput: handler } }
|
34
|
+
if (IS_REACT) { return { onChange: handler } }
|
35
|
+
}
|
36
|
+
|
37
|
+
export default class Autocomplete extends Component {
|
38
|
+
static defaultProps = {
|
39
|
+
autoselect: false,
|
40
|
+
cssNamespace: 'autocomplete',
|
41
|
+
defaultValue: '',
|
42
|
+
displayMenu: 'inline',
|
43
|
+
minLength: 0,
|
44
|
+
name: 'input-autocomplete',
|
45
|
+
placeholder: '',
|
46
|
+
onConfirm: () => {},
|
47
|
+
confirmOnBlur: true,
|
48
|
+
showNoOptionsFound: true,
|
49
|
+
showAllValues: false,
|
50
|
+
required: false,
|
51
|
+
tNoResults: () => 'No results found',
|
52
|
+
tAssistiveHint: () => 'When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.',
|
53
|
+
dropdownArrow: DropdownArrowDown,
|
54
|
+
menuAttributes: {},
|
55
|
+
inputClasses: null,
|
56
|
+
hintClasses: null,
|
57
|
+
menuClasses: null
|
58
|
+
}
|
59
|
+
|
60
|
+
elementReferences = {}
|
61
|
+
|
62
|
+
constructor (props) {
|
63
|
+
super(props)
|
64
|
+
|
65
|
+
this.state = {
|
66
|
+
focused: null,
|
67
|
+
hovered: null,
|
68
|
+
menuOpen: false,
|
69
|
+
options: props.defaultValue ? [props.defaultValue] : [],
|
70
|
+
query: props.defaultValue,
|
71
|
+
validChoiceMade: false,
|
72
|
+
selected: null,
|
73
|
+
ariaHint: true
|
74
|
+
}
|
75
|
+
|
76
|
+
this.handleComponentBlur = this.handleComponentBlur.bind(this)
|
77
|
+
this.handleKeyDown = this.handleKeyDown.bind(this)
|
78
|
+
this.handleUpArrow = this.handleUpArrow.bind(this)
|
79
|
+
this.handleDownArrow = this.handleDownArrow.bind(this)
|
80
|
+
this.handleEnter = this.handleEnter.bind(this)
|
81
|
+
this.handlePrintableKey = this.handlePrintableKey.bind(this)
|
82
|
+
|
83
|
+
this.handleListMouseLeave = this.handleListMouseLeave.bind(this)
|
84
|
+
|
85
|
+
this.handleOptionBlur = this.handleOptionBlur.bind(this)
|
86
|
+
this.handleOptionClick = this.handleOptionClick.bind(this)
|
87
|
+
this.handleOptionFocus = this.handleOptionFocus.bind(this)
|
88
|
+
this.handleOptionMouseDown = this.handleOptionMouseDown.bind(this)
|
89
|
+
this.handleOptionMouseEnter = this.handleOptionMouseEnter.bind(this)
|
90
|
+
|
91
|
+
this.handleInputBlur = this.handleInputBlur.bind(this)
|
92
|
+
this.handleInputChange = this.handleInputChange.bind(this)
|
93
|
+
this.handleInputClick = this.handleInputClick.bind(this)
|
94
|
+
this.handleInputFocus = this.handleInputFocus.bind(this)
|
95
|
+
|
96
|
+
this.pollInputElement = this.pollInputElement.bind(this)
|
97
|
+
this.getDirectInputChanges = this.getDirectInputChanges.bind(this)
|
98
|
+
}
|
99
|
+
|
100
|
+
isQueryAnOption (query, options) {
|
101
|
+
return options.map(entry => this.templateInputValue(entry).toLowerCase()).indexOf(query.toLowerCase()) !== -1
|
102
|
+
}
|
103
|
+
|
104
|
+
componentDidMount () {
|
105
|
+
this.pollInputElement()
|
106
|
+
}
|
107
|
+
|
108
|
+
componentWillUnmount () {
|
109
|
+
clearTimeout(this.$pollInput)
|
110
|
+
}
|
111
|
+
|
112
|
+
// Applications like Dragon NaturallySpeaking will modify the
|
113
|
+
// `input` field by directly changing its `.value`. These events
|
114
|
+
// don't trigger our JavaScript event listeners, so we need to poll
|
115
|
+
// to handle when and if they occur.
|
116
|
+
pollInputElement () {
|
117
|
+
this.getDirectInputChanges()
|
118
|
+
this.$pollInput = setTimeout(() => {
|
119
|
+
this.pollInputElement()
|
120
|
+
}, 100)
|
121
|
+
}
|
122
|
+
|
123
|
+
getDirectInputChanges () {
|
124
|
+
const inputReference = this.elementReferences[-1]
|
125
|
+
const queryHasChanged = inputReference && inputReference.value !== this.state.query
|
126
|
+
|
127
|
+
if (queryHasChanged) {
|
128
|
+
this.handleInputChange({ target: { value: inputReference.value } })
|
129
|
+
}
|
130
|
+
}
|
131
|
+
|
132
|
+
componentDidUpdate (prevProps, prevState) {
|
133
|
+
const { focused } = this.state
|
134
|
+
const componentLostFocus = focused === null
|
135
|
+
const focusedChanged = prevState.focused !== focused
|
136
|
+
const focusDifferentElement = focusedChanged && !componentLostFocus
|
137
|
+
if (focusDifferentElement) {
|
138
|
+
this.elementReferences[focused].focus()
|
139
|
+
}
|
140
|
+
const focusedInput = focused === -1
|
141
|
+
const componentGainedFocus = focusedChanged && prevState.focused === null
|
142
|
+
const selectAllText = focusedInput && componentGainedFocus
|
143
|
+
if (selectAllText) {
|
144
|
+
const inputElement = this.elementReferences[focused]
|
145
|
+
inputElement.setSelectionRange(0, inputElement.value.length)
|
146
|
+
}
|
147
|
+
}
|
148
|
+
|
149
|
+
hasAutoselect () {
|
150
|
+
return isIosDevice() ? false : this.props.autoselect
|
151
|
+
}
|
152
|
+
|
153
|
+
// This template is used when converting from a state.options object into a state.query.
|
154
|
+
templateInputValue (value) {
|
155
|
+
const inputValueTemplate = this.props.templates && this.props.templates.inputValue
|
156
|
+
return inputValueTemplate ? inputValueTemplate(value) : value
|
157
|
+
}
|
158
|
+
|
159
|
+
// This template is used when displaying results / suggestions.
|
160
|
+
templateSuggestion (value) {
|
161
|
+
const suggestionTemplate = this.props.templates && this.props.templates.suggestion
|
162
|
+
return suggestionTemplate ? suggestionTemplate(value) : value
|
163
|
+
}
|
164
|
+
|
165
|
+
handleComponentBlur (newState) {
|
166
|
+
const { options, query, selected } = this.state
|
167
|
+
let newQuery
|
168
|
+
if (this.props.confirmOnBlur) {
|
169
|
+
newQuery = newState.query || query
|
170
|
+
this.props.onConfirm(options[selected])
|
171
|
+
} else {
|
172
|
+
newQuery = query
|
173
|
+
}
|
174
|
+
this.setState({
|
175
|
+
focused: null,
|
176
|
+
menuOpen: newState.menuOpen || false,
|
177
|
+
query: newQuery,
|
178
|
+
selected: null,
|
179
|
+
validChoiceMade: this.isQueryAnOption(newQuery, options)
|
180
|
+
})
|
181
|
+
}
|
182
|
+
|
183
|
+
handleListMouseLeave (event) {
|
184
|
+
this.setState({
|
185
|
+
hovered: null
|
186
|
+
})
|
187
|
+
}
|
188
|
+
|
189
|
+
handleOptionBlur (event, index) {
|
190
|
+
const { focused, menuOpen, options, selected } = this.state
|
191
|
+
const focusingOutsideComponent = event.relatedTarget === null
|
192
|
+
const focusingInput = event.relatedTarget === this.elementReferences[-1]
|
193
|
+
const focusingAnotherOption = focused !== index && focused !== -1
|
194
|
+
const blurComponent = (!focusingAnotherOption && focusingOutsideComponent) || !(focusingAnotherOption || focusingInput)
|
195
|
+
if (blurComponent) {
|
196
|
+
const keepMenuOpen = menuOpen && isIosDevice()
|
197
|
+
this.handleComponentBlur({
|
198
|
+
menuOpen: keepMenuOpen,
|
199
|
+
query: this.templateInputValue(options[selected])
|
200
|
+
})
|
201
|
+
}
|
202
|
+
}
|
203
|
+
|
204
|
+
handleInputBlur (event) {
|
205
|
+
const { focused, menuOpen, options, query, selected } = this.state
|
206
|
+
const focusingAnOption = focused !== -1
|
207
|
+
if (!focusingAnOption) {
|
208
|
+
const keepMenuOpen = menuOpen && isIosDevice()
|
209
|
+
const newQuery = isIosDevice() ? query : this.templateInputValue(options[selected])
|
210
|
+
this.handleComponentBlur({
|
211
|
+
menuOpen: keepMenuOpen,
|
212
|
+
query: newQuery
|
213
|
+
})
|
214
|
+
}
|
215
|
+
}
|
216
|
+
|
217
|
+
handleInputChange (event) {
|
218
|
+
const { minLength, source, showAllValues } = this.props
|
219
|
+
const autoselect = this.hasAutoselect()
|
220
|
+
const query = event.target.value
|
221
|
+
const queryEmpty = query.length === 0
|
222
|
+
const queryChanged = this.state.query !== query
|
223
|
+
const queryLongEnough = query.length >= minLength
|
224
|
+
|
225
|
+
this.setState({
|
226
|
+
query,
|
227
|
+
ariaHint: queryEmpty
|
228
|
+
})
|
229
|
+
|
230
|
+
const searchForOptions = showAllValues || (!queryEmpty && queryChanged && queryLongEnough)
|
231
|
+
if (searchForOptions) {
|
232
|
+
source(query, (options) => {
|
233
|
+
const optionsAvailable = options.length > 0
|
234
|
+
this.setState({
|
235
|
+
menuOpen: optionsAvailable,
|
236
|
+
options,
|
237
|
+
selected: (autoselect && optionsAvailable) ? 0 : -1,
|
238
|
+
validChoiceMade: false
|
239
|
+
})
|
240
|
+
})
|
241
|
+
} else if (queryEmpty || !queryLongEnough) {
|
242
|
+
this.setState({
|
243
|
+
menuOpen: false,
|
244
|
+
options: []
|
245
|
+
})
|
246
|
+
}
|
247
|
+
}
|
248
|
+
|
249
|
+
handleInputClick (event) {
|
250
|
+
this.handleInputChange(event)
|
251
|
+
}
|
252
|
+
|
253
|
+
handleInputFocus (event) {
|
254
|
+
const { query, validChoiceMade, options } = this.state
|
255
|
+
const { minLength } = this.props
|
256
|
+
const shouldReopenMenu = !validChoiceMade && query.length >= minLength && options.length > 0
|
257
|
+
|
258
|
+
if (shouldReopenMenu) {
|
259
|
+
this.setState(({ menuOpen }) => ({ focused: -1, menuOpen: shouldReopenMenu || menuOpen, selected: -1 }))
|
260
|
+
} else {
|
261
|
+
this.setState({ focused: -1 })
|
262
|
+
}
|
263
|
+
}
|
264
|
+
|
265
|
+
handleOptionFocus (index) {
|
266
|
+
this.setState({
|
267
|
+
focused: index,
|
268
|
+
hovered: null,
|
269
|
+
selected: index
|
270
|
+
})
|
271
|
+
}
|
272
|
+
|
273
|
+
handleOptionMouseEnter (event, index) {
|
274
|
+
// iOS Safari prevents click event if mouseenter adds hover background colour
|
275
|
+
// See: https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW4
|
276
|
+
if (!isIosDevice()) {
|
277
|
+
this.setState({
|
278
|
+
hovered: index
|
279
|
+
})
|
280
|
+
}
|
281
|
+
}
|
282
|
+
|
283
|
+
handleOptionClick (event, index) {
|
284
|
+
const selectedOption = this.state.options[index]
|
285
|
+
const newQuery = this.templateInputValue(selectedOption)
|
286
|
+
this.props.onConfirm(selectedOption)
|
287
|
+
this.setState({
|
288
|
+
focused: -1,
|
289
|
+
hovered: null,
|
290
|
+
menuOpen: false,
|
291
|
+
query: newQuery,
|
292
|
+
selected: -1,
|
293
|
+
validChoiceMade: true
|
294
|
+
})
|
295
|
+
this.forceUpdate()
|
296
|
+
}
|
297
|
+
|
298
|
+
handleOptionMouseDown (event) {
|
299
|
+
// Safari triggers focusOut before click, but if you
|
300
|
+
// preventDefault on mouseDown, you can stop that from happening.
|
301
|
+
// If this is removed, clicking on an option in Safari will trigger
|
302
|
+
// `handleOptionBlur`, which closes the menu, and the click will
|
303
|
+
// trigger on the element underneath instead.
|
304
|
+
// See: http://stackoverflow.com/questions/7621711/how-to-prevent-blur-running-when-clicking-a-link-in-jquery
|
305
|
+
event.preventDefault()
|
306
|
+
}
|
307
|
+
|
308
|
+
handleUpArrow (event) {
|
309
|
+
event.preventDefault()
|
310
|
+
const { menuOpen, selected } = this.state
|
311
|
+
const isNotAtTop = selected !== -1
|
312
|
+
const allowMoveUp = isNotAtTop && menuOpen
|
313
|
+
if (allowMoveUp) {
|
314
|
+
this.handleOptionFocus(selected - 1)
|
315
|
+
}
|
316
|
+
}
|
317
|
+
|
318
|
+
handleDownArrow (event) {
|
319
|
+
event.preventDefault()
|
320
|
+
// if not open, open
|
321
|
+
if (this.props.showAllValues && this.state.menuOpen === false) {
|
322
|
+
event.preventDefault()
|
323
|
+
this.props.source('', (options) => {
|
324
|
+
this.setState({
|
325
|
+
menuOpen: true,
|
326
|
+
options,
|
327
|
+
selected: 0,
|
328
|
+
focused: 0,
|
329
|
+
hovered: null
|
330
|
+
})
|
331
|
+
})
|
332
|
+
} else if (this.state.menuOpen === true) {
|
333
|
+
const { menuOpen, options, selected } = this.state
|
334
|
+
const isNotAtBottom = selected !== options.length - 1
|
335
|
+
const allowMoveDown = isNotAtBottom && menuOpen
|
336
|
+
if (allowMoveDown) {
|
337
|
+
this.handleOptionFocus(selected + 1)
|
338
|
+
}
|
339
|
+
}
|
340
|
+
}
|
341
|
+
|
342
|
+
handleSpace (event) {
|
343
|
+
// if not open, open
|
344
|
+
if (this.props.showAllValues && this.state.menuOpen === false && this.state.query === '') {
|
345
|
+
event.preventDefault()
|
346
|
+
this.props.source('', (options) => {
|
347
|
+
this.setState({
|
348
|
+
menuOpen: true,
|
349
|
+
options
|
350
|
+
})
|
351
|
+
})
|
352
|
+
}
|
353
|
+
const focusIsOnOption = this.state.focused !== -1
|
354
|
+
if (focusIsOnOption) {
|
355
|
+
event.preventDefault()
|
356
|
+
this.handleOptionClick(event, this.state.focused)
|
357
|
+
}
|
358
|
+
}
|
359
|
+
|
360
|
+
handleEnter (event) {
|
361
|
+
if (this.state.menuOpen) {
|
362
|
+
event.preventDefault()
|
363
|
+
const hasSelectedOption = this.state.selected >= 0
|
364
|
+
if (hasSelectedOption) {
|
365
|
+
this.handleOptionClick(event, this.state.selected)
|
366
|
+
}
|
367
|
+
}
|
368
|
+
}
|
369
|
+
|
370
|
+
handlePrintableKey (event) {
|
371
|
+
const inputElement = this.elementReferences[-1]
|
372
|
+
const eventIsOnInput = event.target === inputElement
|
373
|
+
if (!eventIsOnInput) {
|
374
|
+
// FIXME: This would be better if it was in componentDidUpdate,
|
375
|
+
// but using setState to trigger that seems to not work correctly
|
376
|
+
// in preact@8.1.0.
|
377
|
+
inputElement.focus()
|
378
|
+
}
|
379
|
+
}
|
380
|
+
|
381
|
+
handleKeyDown (event) {
|
382
|
+
switch (keyCodes[event.keyCode]) {
|
383
|
+
case 'up':
|
384
|
+
this.handleUpArrow(event)
|
385
|
+
break
|
386
|
+
case 'down':
|
387
|
+
this.handleDownArrow(event)
|
388
|
+
break
|
389
|
+
case 'space':
|
390
|
+
this.handleSpace(event)
|
391
|
+
break
|
392
|
+
case 'enter':
|
393
|
+
this.handleEnter(event)
|
394
|
+
break
|
395
|
+
case 'escape':
|
396
|
+
this.handleComponentBlur({
|
397
|
+
query: this.state.query
|
398
|
+
})
|
399
|
+
break
|
400
|
+
default:
|
401
|
+
if (isPrintableKeyCode(event.keyCode)) {
|
402
|
+
this.handlePrintableKey(event)
|
403
|
+
}
|
404
|
+
break
|
405
|
+
}
|
406
|
+
}
|
407
|
+
|
408
|
+
render () {
|
409
|
+
const {
|
410
|
+
cssNamespace,
|
411
|
+
displayMenu,
|
412
|
+
id,
|
413
|
+
minLength,
|
414
|
+
name,
|
415
|
+
placeholder,
|
416
|
+
required,
|
417
|
+
showAllValues,
|
418
|
+
tNoResults,
|
419
|
+
tStatusQueryTooShort,
|
420
|
+
tStatusNoResults,
|
421
|
+
tStatusSelectedOption,
|
422
|
+
tStatusResults,
|
423
|
+
tAssistiveHint,
|
424
|
+
dropdownArrow: dropdownArrowFactory,
|
425
|
+
menuAttributes,
|
426
|
+
inputClasses,
|
427
|
+
hintClasses,
|
428
|
+
menuClasses
|
429
|
+
} = this.props
|
430
|
+
const { focused, hovered, menuOpen, options, query, selected, ariaHint, validChoiceMade } = this.state
|
431
|
+
const autoselect = this.hasAutoselect()
|
432
|
+
|
433
|
+
const inputFocused = focused === -1
|
434
|
+
const noOptionsAvailable = options.length === 0
|
435
|
+
const queryNotEmpty = query.length !== 0
|
436
|
+
const queryLongEnough = query.length >= minLength
|
437
|
+
const showNoOptionsFound = this.props.showNoOptionsFound &&
|
438
|
+
inputFocused && noOptionsAvailable && queryNotEmpty && queryLongEnough
|
439
|
+
|
440
|
+
const wrapperClassName = `${cssNamespace}__wrapper`
|
441
|
+
const statusClassName = `${cssNamespace}__status`
|
442
|
+
const dropdownArrowClassName = `${cssNamespace}__dropdown-arrow-down`
|
443
|
+
const optionFocused = focused !== -1 && focused !== null
|
444
|
+
|
445
|
+
const optionClassName = `${cssNamespace}__option`
|
446
|
+
|
447
|
+
const hintClassName = `${cssNamespace}__hint`
|
448
|
+
const selectedOptionText = this.templateInputValue(options[selected])
|
449
|
+
const optionBeginsWithQuery = selectedOptionText &&
|
450
|
+
selectedOptionText.toLowerCase().indexOf(query.toLowerCase()) === 0
|
451
|
+
const hintValue = (optionBeginsWithQuery && autoselect)
|
452
|
+
? query + selectedOptionText.substr(query.length)
|
453
|
+
: ''
|
454
|
+
|
455
|
+
const assistiveHintID = id + '__assistiveHint'
|
456
|
+
const ariaProps = {
|
457
|
+
'aria-describedby': ariaHint ? assistiveHintID : null,
|
458
|
+
'aria-expanded': menuOpen ? 'true' : 'false',
|
459
|
+
'aria-activedescendant': optionFocused ? `${id}__option--${focused}` : null,
|
460
|
+
'aria-owns': `${id}__listbox`,
|
461
|
+
'aria-autocomplete': (this.hasAutoselect()) ? 'both' : 'list'
|
462
|
+
}
|
463
|
+
|
464
|
+
let dropdownArrow
|
465
|
+
|
466
|
+
// we only need a dropdown arrow if showAllValues is set to a truthy value
|
467
|
+
if (showAllValues) {
|
468
|
+
dropdownArrow = dropdownArrowFactory({ className: dropdownArrowClassName })
|
469
|
+
|
470
|
+
// if the factory returns a string we'll render this as HTML (usage w/o (P)React)
|
471
|
+
if (typeof dropdownArrow === 'string') {
|
472
|
+
dropdownArrow = <div className={`${cssNamespace}__dropdown-arrow-down-wrapper`} dangerouslySetInnerHTML={{ __html: dropdownArrow }} />
|
473
|
+
}
|
474
|
+
}
|
475
|
+
|
476
|
+
const inputClassName = `${cssNamespace}__input`
|
477
|
+
const inputClassList = [
|
478
|
+
inputClassName,
|
479
|
+
this.props.showAllValues ? `${inputClassName}--show-all-values` : `${inputClassName}--default`
|
480
|
+
]
|
481
|
+
|
482
|
+
const componentIsFocused = focused !== null
|
483
|
+
if (componentIsFocused) {
|
484
|
+
inputClassList.push(`${inputClassName}--focused`)
|
485
|
+
}
|
486
|
+
|
487
|
+
if (inputClasses) {
|
488
|
+
inputClassList.push(inputClasses)
|
489
|
+
}
|
490
|
+
|
491
|
+
const menuClassName = `${cssNamespace}__menu`
|
492
|
+
const menuModifierDisplayMenu = `${menuClassName}--${displayMenu}`
|
493
|
+
const menuIsVisible = menuOpen || showNoOptionsFound
|
494
|
+
const menuModifierVisibility = `${menuClassName}--${(menuIsVisible) ? 'visible' : 'hidden'}`
|
495
|
+
|
496
|
+
const menuClassList = [
|
497
|
+
menuClassName,
|
498
|
+
menuModifierDisplayMenu,
|
499
|
+
menuModifierVisibility
|
500
|
+
]
|
501
|
+
|
502
|
+
if (menuClasses) {
|
503
|
+
menuClassList.push(menuClasses)
|
504
|
+
}
|
505
|
+
|
506
|
+
if (menuAttributes?.class || menuAttributes?.className) {
|
507
|
+
menuClassList.push(menuAttributes?.class || menuAttributes?.className)
|
508
|
+
}
|
509
|
+
|
510
|
+
const computedMenuAttributes = {
|
511
|
+
// Copy the attributes passed as props
|
512
|
+
...menuAttributes,
|
513
|
+
// And add the values computed for the autocomplete
|
514
|
+
id: `${id}__listbox`,
|
515
|
+
role: 'listbox',
|
516
|
+
className: menuClassList.join(' '),
|
517
|
+
onMouseLeave: this.handleListMouseLeave
|
518
|
+
}
|
519
|
+
|
520
|
+
// Preact would override our computed `className`
|
521
|
+
// with the `class` from the `menuAttributes` so
|
522
|
+
// we need to clean it up from the computed attributes
|
523
|
+
delete computedMenuAttributes.class
|
524
|
+
|
525
|
+
return (
|
526
|
+
<div className={wrapperClassName} onKeyDown={this.handleKeyDown}>
|
527
|
+
<Status
|
528
|
+
id={id}
|
529
|
+
length={options.length}
|
530
|
+
queryLength={query.length}
|
531
|
+
minQueryLength={minLength}
|
532
|
+
selectedOption={this.templateInputValue(options[selected])}
|
533
|
+
selectedOptionIndex={selected}
|
534
|
+
validChoiceMade={validChoiceMade}
|
535
|
+
isInFocus={this.state.focused !== null}
|
536
|
+
tQueryTooShort={tStatusQueryTooShort}
|
537
|
+
tNoResults={tStatusNoResults}
|
538
|
+
tSelectedOption={tStatusSelectedOption}
|
539
|
+
tResults={tStatusResults}
|
540
|
+
className={statusClassName}
|
541
|
+
/>
|
542
|
+
|
543
|
+
{hintValue && (
|
544
|
+
<span><input className={[hintClassName, hintClasses === null ? inputClasses : hintClasses].filter(Boolean).join(' ')} readonly tabIndex='-1' value={hintValue} /></span>
|
545
|
+
)}
|
546
|
+
|
547
|
+
<input
|
548
|
+
{...ariaProps}
|
549
|
+
autoComplete='off'
|
550
|
+
className={inputClassList.join(' ')}
|
551
|
+
id={id}
|
552
|
+
onClick={this.handleInputClick}
|
553
|
+
onBlur={this.handleInputBlur}
|
554
|
+
{...onChangeCrossLibrary(this.handleInputChange)}
|
555
|
+
onFocus={this.handleInputFocus}
|
556
|
+
name={name}
|
557
|
+
placeholder={placeholder}
|
558
|
+
ref={(inputElement) => { this.elementReferences[-1] = inputElement }}
|
559
|
+
type='text'
|
560
|
+
role='combobox'
|
561
|
+
required={required}
|
562
|
+
value={query}
|
563
|
+
/>
|
564
|
+
|
565
|
+
{dropdownArrow}
|
566
|
+
|
567
|
+
<ul {...computedMenuAttributes}>
|
568
|
+
{options.map((option, index) => {
|
569
|
+
const showFocused = focused === -1 ? selected === index : focused === index
|
570
|
+
const optionModifierFocused = showFocused && hovered === null ? ` ${optionClassName}--focused` : ''
|
571
|
+
const optionModifierOdd = (index % 2) ? ` ${optionClassName}--odd` : ''
|
572
|
+
const iosPosinsetHtml = (isIosDevice())
|
573
|
+
? `<span id=${id}__option-suffix--${index} style="border:0;clip:rect(0 0 0 0);height:1px;` +
|
574
|
+
'marginBottom:-1px;marginRight:-1px;overflow:hidden;padding:0;position:absolute;' +
|
575
|
+
'whiteSpace:nowrap;width:1px">' + ` ${index + 1} of ${options.length}</span>`
|
576
|
+
: ''
|
577
|
+
|
578
|
+
return (
|
579
|
+
<li
|
580
|
+
aria-selected={focused === index ? 'true' : 'false'}
|
581
|
+
className={`${optionClassName}${optionModifierFocused}${optionModifierOdd}`}
|
582
|
+
dangerouslySetInnerHTML={{ __html: this.templateSuggestion(option) + iosPosinsetHtml }}
|
583
|
+
id={`${id}__option--${index}`}
|
584
|
+
key={index}
|
585
|
+
onBlur={(event) => this.handleOptionBlur(event, index)}
|
586
|
+
onClick={(event) => this.handleOptionClick(event, index)}
|
587
|
+
onMouseDown={this.handleOptionMouseDown}
|
588
|
+
onMouseEnter={(event) => this.handleOptionMouseEnter(event, index)}
|
589
|
+
ref={(optionEl) => { this.elementReferences[index] = optionEl }}
|
590
|
+
role='option'
|
591
|
+
tabIndex='-1'
|
592
|
+
aria-posinset={index + 1}
|
593
|
+
aria-setsize={options.length}
|
594
|
+
/>
|
595
|
+
)
|
596
|
+
})}
|
597
|
+
|
598
|
+
{showNoOptionsFound && (
|
599
|
+
<li className={`${optionClassName} ${optionClassName}--no-results`}>{tNoResults()}</li>
|
600
|
+
)}
|
601
|
+
</ul>
|
602
|
+
|
603
|
+
<span id={assistiveHintID} style={{ display: 'none' }}>{tAssistiveHint()}</span>
|
604
|
+
|
605
|
+
</div>
|
606
|
+
)
|
607
|
+
}
|
608
|
+
}
|
@@ -0,0 +1,11 @@
|
|
1
|
+
import { createElement } from 'preact' /** @jsx createElement */
|
2
|
+
|
3
|
+
const DropdownArrowDown = ({ className }) => (
|
4
|
+
<svg version='1.1' xmlns='http://www.w3.org/2000/svg' className={className} focusable='false'>
|
5
|
+
<g stroke='none' fill='none' fill-rule='evenodd'>
|
6
|
+
<polygon fill='#000000' points='0 0 22 0 11 17' />
|
7
|
+
</g>
|
8
|
+
</svg>
|
9
|
+
)
|
10
|
+
|
11
|
+
export default DropdownArrowDown
|