-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathuber_search.js
More file actions
459 lines (379 loc) · 16.5 KB
/
uber_search.js
File metadata and controls
459 lines (379 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//= require_self
//= require_tree ./uber_search
(function($) {
window.UberSearch = function(data, options){
var eventsTriggered = {
shown: 'shown',
renderedResults: 'renderedResults',
clear: 'clear',
select: 'select'
}
options = $.extend({
uberSelectId: generateUUID(), // A unique identifier for select
ariaLabel: null, // Label of the select for screen readers
value: null, // Initialize with this selectedValue
disabled: false, // Initialize with this disabled value
search: true, // Show the search input
clearSearchButton:'✕', // Text content of clear search button
selectCaret: '⌄', // Text content of select caret
hideBlankOption: false, // Should blank options be hidden automatically?
treatBlankOptionAsPlaceholder: false, // Should blank options use the placeholder as text?
highlightByDefault: true, // Should the first result be auto-highlighted?
minQueryLength: 0, // Number of characters to type before results are displayed
minQueryMessage: true, // Message to show when the query doesn't exceed the minimum length. True for default, false for none, or custom message.
placeholder: null, // Placeholder to show in the selected text area
searchPlaceholder: 'Type to search', // Placeholder to show in the search input
noResultsText: 'No Matches Found', // The message shown when there are no results
resultPostprocessor: function(result, datum){}, // A function that is run after a result is built and can be used to decorate it
buildResult: null, // A function that is used to build result elements
outputContainer: null, // An object that receives the output once a result is selected. Must respond to setValue(value), and view()
onRender: function(resultsContainer, result) {}, // A function to run when the results container is rendered. If the result returns false, the default render handler is not run and the event is cancelled
onSelect: function(datum, result, clickEvent) {}, // A function to run when a result is selected. If the result returns false, the default select handler is not run and the event is cancelled
onNoHighlightSubmit: function(value) {}, // A function to run when a user presses enter without selecting a result.
noDataText: 'No options', // Text to show in there is nothing in the set of data to pick from
matchGroupNames: false, // Show results for searches that match the result's group name
alwaysOpen: false // Should the options list always appear open?
}, options)
var context = this
var view = $('<span>', { class: "uber_select", id: options.uberSelectId })
var selectedValue = options.value // Internally selected value
var outputContainer = options.outputContainer || new UberSearch.OutputContainer({selectCaret: options.selectCaret, ariaLabel: options.ariaLabel})
var resultsContainer = $('<div class="results_container"></div>')
var messages = $('<div class="messages"></div>')
var pane = new UberSearch.Pane({alwaysOpen: options.alwaysOpen})
var searchField = new UberSearch.SearchField({
placeholder: options.searchPlaceholder,
clearButton: options.clearSearchButton,
searchInputAttributes: options.searchInputAttributes
})
var search = new UberSearch.Search(searchField.input, resultsContainer, {
model: {
dataForMatching: dataForMatching,
minQueryLength: options.minQueryLength,
queryPreprocessor: options.queryPreprocessor || UberSearch.Search.prototype.queryPreprocessor,
datumPreprocessor: options.datumPreprocessor || datumPreprocessor,
patternForMatching: options.patternForMatching || UberSearch.Search.prototype.patternForMatching
},
view: {
renderResults: renderResults,
buildResult: options.buildResult || buildResult,
}
})
// BEHAVIOUR
// Hide the pane when clicked out or another pane is opened
$(document).on('click shown', function(event){
if (!options.alwaysOpen && pane.isOpen() && isEventOutside(event)){
pane.hide()
}
})
// Hide the pane when tabbing away from view
$(view).on('keydown', function(event){
if (!options.alwaysOpen && pane.isOpen() && event.which === 9) {
pane.hide()
}
})
$(view).on('setHighlight', function(event, result, index) {
if (index < 0 && options.search) {
setOutputContainerAria("aria-activedescendant", "")
$(searchField.input).focus()
} else if (index < 0) {
setOutputContainerAria("aria-activedescendant", "")
$(outputContainer.view).focus()
} else if (result) {
// Check if result exists (e.g., when pressing down on the last item, result will be undefined)
setOutputContainerAria("aria-activedescendant", result.id)
} else {
setOutputContainerAria("aria-activedescendant", "")
}
})
$(view).on('inputDownArrow', function(event) {
search.stepHighlight(1)
})
$(view).on('inputUpArrow', function(event) {
outputContainer.view.focus()
})
// Show the pane if the user was tabbed onto the trigger and pressed enter, space, or down arrow
$(outputContainer.view).on('keydown', function(event){
if (outputContainer.view.hasClass('disabled')) { return }
if (event.which === 40) { // open and focus pane when down key is pressed
if (pane.isClosed()) {
pane.show()
} else if (options.search) {
$(searchField.input).focus()
} else {
search.stepHighlight(1)
}
return false
}
if (event.which === 32 || event.which === 13){ // toggle pane when space or enter is pressed
pane.toggle()
return false
}
})
// Show the pane when the select element is clicked
$(outputContainer.view).on('click', function(event){
if (outputContainer.view.hasClass('disabled')) { return }
pane.show()
})
// When the pane is opened
$(pane).on('shown', function(){
setOutputContainerAria('aria-expanded', true)
search.clear()
markSelected(true)
view.addClass('open')
if (options.search) {
$(searchField.input).focus()
}
triggerEvent(eventsTriggered.shown)
})
// When the pane is hidden
$(pane).on('hidden', function(){
setOutputContainerAria('aria-expanded', false)
view.removeClass('open')
view.focus()
})
// When the query is changed
$(search).on('queryChanged', function(){
updateMessages()
})
// When the search results are rendered
$(search).on('renderedResults', function(event){
if (options.onRender(resultsContainer, getSelection()) === false) {
event.stopPropagation()
return
}
markSelected()
updateMessages()
triggerEvent(eventsTriggered.renderedResults)
})
// When the search field is cleared
$(searchField).on('clear', function(){
triggerEvent(eventsTriggered.clear)
})
// When a search result is chosen
resultsContainer.on('click', '.result:not(.disabled)', function(event){
var datum = $(this).data()
if (options.onSelect(datum, this, event) === false) {
event.stopPropagation()
return
}
event.stopPropagation();
setValue(valueFromResult(this))
if (!options.alwaysOpen) {
pane.hide()
}
triggerEvent(eventsTriggered.select, [datum, this, event])
})
// When query is submitted
$(searchField.input).on('noHighlightSubmit', function(event) {
options.onNoHighlightSubmit($(this).val())
})
// INITIALIZATION
setDisabled(options.disabled)
setData(data)
if (options.search) { pane.addContent('search', searchField.view) }
pane.addContent('messages', messages)
pane.addContent('results', resultsContainer)
// If the output container isn't in the DOM yet, add it
if (!$(outputContainer.view).closest('body').length){
$(outputContainer.view).appendTo(view)
}
$(view).append(pane.view)
updateMessages()
updateSelectedText()
markSelected()
if (options.alwaysOpen) {
pane.show()
}
// HELPER FUNCTIONS
function generateUUID() {
// https://www.w3resource.com/javascript-exercises/javascript-math-exercise-23.php
var dt = new Date().getTime();
var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = (dt + Math.random()*16)%16 | 0;
dt = Math.floor(dt/16);
return (c=='x' ? r :(r&0x3|0x8)).toString(16);
});
return uuid;
}
function setData(newData){
data = setDataDefaults(newData)
search.setData(data)
updateSelectedText()
markSelected()
}
// Selects the result corresponding to the given value
function setValue(value){
if (selectedValue == value) { return }
selectedValue = value
updateSelectedText()
markSelected()
}
// Returns the selected value
function getValue(){
return selectedValue
}
// Enables or disables UberSearch
function setDisabled(boolean){
outputContainer.setDisabled(boolean)
}
// Updates the enhanced select with the text of the selected result
function setSelectedText(text){
if (text) {
outputContainer.setValue(text)
} else {
outputContainer.setValue(options.placeholder)
}
}
// Inherit values for matchValue and value from text
function setDataDefaults(data){
return $.map(data, function(datum) {
return $.extend({ value: datum.text, matchValue: datum.text }, datum)
})
}
// Converts the dataFromSelect into a datum list for matching
function dataForMatching(processedQuery, data){
// If a query is present, include only select options that should be used when searching
// Else, include only options that should be visible when not searching
if (processedQuery) {
return $.map(data, function(datum){ if (datum.visibility != 'no-query' || datum.value == selectedValue) return datum })
} else {
return $.map(data, function(datum){ if (datum.visibility != 'query' || datum.value == selectedValue) return datum })
}
}
// Match against the datum.group and datum.matchValue
function datumPreprocessor(datum){
if (options.matchGroupNames && datum.group) {
return datum.group + " " + datum.matchValue
} else {
return datum.matchValue
}
}
// Adds group support and blank option hiding
function renderResults(data){
var context = this
var sourceArray = []
$.each(data, function(index, datum){
// Add the group name so we can group items
var result = context.buildResult(index, datum).attr('data-group', datum.group)
// Omit blank option from results
if (!options.hideBlankOption || datum.value){
sourceArray.push(result)
}
})
// Arrange ungrouped list items
var destArray = reject(sourceArray, 'li:not([data-group])')
// Arrange list items into sub lists
while (sourceArray.length) {
var group = $(sourceArray[0]).attr('data-group')
var groupNodes = reject(sourceArray, 'li[data-group="' + group + '"]')
var sublist = $('<ul>', { class: "sublist", 'data-group': group })
var sublistNode = $('<li>', { role: "listitem", 'aria-label': group }).append($('<span>', { class: "sublist_name" }).html(group))
sublist.append(groupNodes)
sublistNode.append(sublist)
destArray.push(sublistNode)
}
this.view.toggleClass('empty', !destArray.length)
this.view.html(destArray)
}
// Removes elements from the sourcArray that match the selector
// Returns an array of removed elements
function reject(sourceArray, selector){
var dest = filter(sourceArray, selector)
var source = filter(sourceArray, selector, true)
sourceArray.splice(0, sourceArray.length)
sourceArray.push.apply(sourceArray, source)
return dest
}
function filter(sourceArray, selector, invert){
return $.grep(sourceArray, function(node){ return node.is(selector) }, invert)
}
function buildResult(index, datum){
var text = (options.treatBlankOptionAsPlaceholder ? datum.text || options.placeholder : datum.text);
var result = $('<li class="result" role="listitem" tabindex="-1"></li>') // Use -1 tabindex so that the result can be focusable but not tabbable.
.attr('id', (options.uberSelectId + "-" + index))
.text(text || String.fromCharCode(160)) // Insert text or
.data(datum) // Store the datum so we can get know what the value of the selected item is
if (datum.title) { result.attr('title', datum.title) }
if (datum.disabled) { result.addClass('disabled') }
options.resultPostprocessor(result, datum)
return result
}
function markSelected(focus) {
focus = focus || false
var selected = getSelection()
var results = search.getResults()
$(results).filter('.selected').not(selected).removeClass('selected').attr('aria-selected', false)
// Ensure the selected result is unhidden
$(selected).addClass('selected').removeClass('hidden')
$(selected).attr('aria-selected', true)
if (selected) {
search.highlightResult(selected, { focus: focus })
} else if (options.highlightByDefault) {
search.highlightResult(results.not('.hidden').not('.disabled').first(), { focus: focus })
}
}
// Returns the selected element and its index
function getSelection(){
var results = search.getResults()
var selected
$.each(results, function(i, result){
if (selectedValue == valueFromResult(result)){
selected = result
return false
}
})
return selected
}
function valueFromResult(result){
return $(result).data('value')
}
function updateSelectedText(){
setSelectedText(textFromValue(selectedValue))
}
function textFromValue(value){
return $.map(data, function(datum) {
if (datum.value == value) {
return datum.selectedText || datum.text
}
})[0]
}
function updateMessages(){
messages.show()
if (!queryLength() && !resultsCount()){
messages.html(options.noDataText)
} else if (options.minQueryLength && options.minQueryMessage && queryLength() < options.minQueryLength){
messages.html(options.minQueryMessage === true ? 'Type at least ' + options.minQueryLength + (options.minQueryLength == 1 ? ' character' : ' characters') + ' to search' : options.minQueryMessage)
} else if (options.noResultsText && !resultsCount()){
messages.html(options.noResultsText)
} else {
messages.empty().hide()
}
}
function queryLength(){
return search.getQuery().length
}
function resultsCount(){
return search.getResults().length
}
function setOutputContainerAria() {
outputContainer.view.attr.apply(outputContainer.view, arguments)
}
// returns true if the event originated outside this component
function isEventOutside(event){
event = event.originalEvent || event // Handle both jQuery events and standard JS events
if (event.composed) { // Support UberSelect when used in the Shadow DOM
return !event.composedPath().includes(view[0])
} else {
return !$(event.target).closest(view).length
}
}
// Allow observer to be attached to the UberSearch itself
function triggerEvent(eventType, callbackArgs){
view.trigger(eventType, callbackArgs)
$(context).triggerHandler(eventType, callbackArgs)
}
// PUBLIC INTERFACE
$.extend(this, {view:view, searchField:searchField, setValue:setValue, getValue: getValue, setData:setData, setDisabled:setDisabled, getSelection:getSelection, options:options})
}
})(jQuery)