-
Notifications
You must be signed in to change notification settings - Fork 2
/
quickspot.js
1621 lines (1397 loc) · 50.2 KB
/
quickspot.js
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
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*!
* Quick-spot a fast flexible JSON powered in memory search.
*
* @author Carl Saggs
* @repo https://github.com/thybag/quick-spot
*/
(function(){
"use strict";
// Privately scoped quickspot object (we talk to the real world (global scope) via the attach method)
// Additional methods are available in initialised instance & within callbacks
var quickspot = function() {
// Internal datastore
this.datastore = null;
// Internal data
this.results = [];
this.selectedIndex = 0; // index of currently selected result
this.target = null; // input acting as search box
this.dom = null; // ref to search results DOM object
this.container = null; // ref to container DOM object
this.reader = null; // ref to node containing screen reader helper
this.lastValue = ""; // last searched value
this.resultsVisible = false; // are search results currently visible
this.eventsReady = false; // Is QS ready to start attaching events?
this.ready = false;
// "here" is kinda a global "this" for quickspot
var here = this;
var methods = {};
var eventsQueue = []; // Queue of events waiting to be added (used for events attached while QS is still initing)
// Public version of attach.
this.attach = function(options) {
if (this.target) {
console.log("[Quickspot] This quickspot instance has already attached.");
return;
}
// Don't wait if document is already ready or safe load is turned off
if (document.readyState === "complete" || options.safeload === false) {
methods.attach(options);
} else {
util.addListener(window, "load", function(){
methods.attach(options);
});
}
};
// Public method to change "datastore" powering quickspot
this.setDatastore = function(store) {
if (typeof store.store !== "undefined") {
// Still loading, setup hook and wait
if (store.store === null) {
store.__loaded = function(ds){ here.setDatastore(ds); };
return;
}
// Loaded, grab the "real store"
store = store.store;
}
// Normal datastore? set er up
this.datastore = store;
// Fire callback if needed
if (this.eventsReady) util.triggerEvent(this.target, "quickspot:loaded", this);
if (typeof this.options.loaded !== "undefined") this.options.loaded(this.datastore);
// refresh data
if (this.ready) this.refresh();
// Make chainable.
return this;
};
// Force quickspot to show "all" results
this.showAll = function(unfiltered, custom_sort, prevent_autofocus) {
// default options
this.target.value = "";
this.lastValue = "";
if (typeof prevent_autofocus === "undefined" || !prevent_autofocus) {
this.target.focus();
}
// Grab data set
here.results = here.datastore.all(unfiltered, custom_sort).get();
// & render it all
methods.render_results(here.results);
methods.showResults();
// Make chainable.
return this;
};
// Event listener helper
this.on = function(event, callback) {
if (here.eventsReady) {
util.addListener(here.target, event, callback);
} else {
// Queue for later
eventsQueue.push({"event": event, "callback": callback});
}
// Make chainable.
return this;
};
// show helper
this.showResults = function() {
methods.showResults();
// Make chainable.
return this;
};
// hide helper
this.hideResults = function() {
methods.hideResults();
// Make chainable.
return this;
};
// Refresh current listing
this.refresh = function() {
methods.refresh();
// Make chainable.
return this;
};
// Apply filter to datastore - this will apply to all search values until clearFilters is called.
// Multiple filters can be added
this.filter = function(filter_value, filter_column) {
this.datastore.filter(filter_value, filter_column);
// Make chainable.
return this;
};
// Clear all filters currently attached to datastore
this.clearFilters = function() {
this.datastore.clear_filters();
// Make chainable.
return this;
};
/**
* Attach a new quick-spot search to the page
*
** Required
* @param options.target - DOM node or ID of element to use (or callback that returns this)
*
** One of
* @param options.url - URL of JSON feed to search with
* @param options.data - data to search on provided as raw JavaScript object
*
** Advanced configuration
* @param options.key_value - attribute containing key bit of information (name used by default)
* @param options.display_name - name of attribute to display in box (uses key_value by default)
* @param options.search_on - array of attributes to search on (Quickspot will search on all attributes in an object if this is not provided)
* @param options.disable_occurrence_weighting - if true, occurrences will not weight results
* @param options.safeload - QS will attempt to attach instantly, rather than waiting for document load
* @param options.hide_on_blur - Hide listing on blur (true by default)
* @param options.results_container - DOM node or ID for container quick-spot results will be shown in (by default will use the quick-spot elements parent)
* @param options.prevent_headers - Don't add custom headers such as X-Requested-With (will avoid options requests)
* @param options.auto_highlight - Automatically attempt to highlight search text in result items. (true|false - default false)
* @param options.max_results - Maximum results to display at any one time (after searching/ordering, results after the cut off won't be rendered. 0 = unlimited)
* @param options.css_class_prefix - Defaults to "quickspot". Can be used to namespace quickspot classes
* @param options.allow_partial_matches - Filter results by individual words rather than by the full phrase. This is enabled by default. (true|false)
* @param options.show_all_on_blank_search - Rather than hiding the results (default) - instead show full results list when no search term has been entered
* @param options.events - Quick way of binding some initial events on load. Simply an object containing event/callback pairs.
*
** Extend methods
* @param options.display_handler - overwrites default display method.
* @param options.click_handler - Callback method, is passed the selected item & qs instance.
* @param options.gen_score - callback to set custom score method. (higher number = higher in results order)
* @param options.no_results - Item to show when no results are found (false to do nothing)
* @param options.no_results_click - action when "no results" item is clicked
* @param options.no_search_handler - action when no search is entered
* @param options.string_filter - parse string for quickspot searching (Default will make string lower case, and remove punctuation characters)
* @param options.loaded - callback fired when a data store has been loaded
* @param options.ready - callback fired when quick-spot up & running
* @param options.data_pre_parse - callback provided with raw data object & options - can be used to rearrange data to work with quick-spot (if needed)
* @param options.parse_results - Manipulate result array before render.
* @param options.hide_results - override method to hide results container
* @param options.show_results - override method to show results container
* @param options.display_handler - overwrites default display method.
* @param options.screenreader_result_text - overwrite method used to return "screen reader text" from a given result object
* @param options.results_header - Callback that returns either a DOM element or markup for the results box header
* @param options.results_footer - Callback that returns either a DOM element or markup for the results box footer
* @param options.error - callback fired on AJAX failure
*
* Methods
* qs.refresh() - refresh current search results (use when updating a datastore)
* qs.showResults() - Show results
* qs.hideResults() - Hide results
* qs.setDatastore(newDatastore) - Set a new datastore
* qs.on(event, callback) - Attach an event
* qs.filter(value, filter_on_attribuite) - Applys filter to data set
* qs.clearFilters() - removes filters from dataset
*
** Events
* quickspot:start - search is triggered
* quickspot:end - search is completed
* quickspot:showresults - whenever result container is displayed
* quickspot:hideresults - whenever results container is hidden
* quickspot:activate - quick-spot gets focus
* quickspot:select - new result gets focus
* quickspot:result - result is shown
* quickspot:resultsfound - search completes with results
* quickspot:noresult - search completes with no results
* quickspot:loaded - When a quickspot datastore is loaded
* quickspot:ready - When quickspot is ready
*/
methods.attach = function(options) {
// Merge passed in options into options obj
for (var i in options){
here.options[i] = options[i];
}
// Check we have a target!
if (!options.target){
console.log("[Quickspot] Target not specified");
return;
}
// Get target
here.target = methods.get_option_contents_as_node(here.options.target, false);
if (!here.target){
console.log("[Quickspot] Target ID could not be found");
return;
}
// Connect any user provided event listeners
here.eventsReady = true;
methods.attachQueuedEvents();
// Grab display name
if (typeof here.options.display_name === "undefined"){
here.options.display_name = here.options.key_value;
}
// Set init to wait for final load.
here.on("quickspot:init", methods.init);
// Find data
if (typeof here.options.url !== "undefined") {
//Load data via ajax
util.ajaxGetJSON(here.options, methods.initialise_data);
} else if (typeof here.options.data !== "undefined") {
//Import directly provided data
methods.initialise_data(options.data);
} else {
//Warn user if none is provided
console.log("[Quickspot] No datasource provided.");
return;
}
};
/**
* Init - generate additional markup & hook up events on QS load
*
*/
methods.init = function() {
// Setup basic DOM stuff for results
here.dom = document.createElement("div");
here.dom.className = here.options.css_class_prefix + "-results";
// Get container
if (typeof here.options.results_container === "undefined"){
// Create QS container and add it to the DOM
here.container = document.createElement("div");
here.target.parentNode.appendChild(here.container);
} else {
// use existing QS container
here.container = methods.get_option_contents_as_node(here.options.results_container, false);
}
// Set container attributes
here.container.setAttribute("tabindex", "100");
here.container.style.display = "none";
here.container.className = here.container.className + " " + here.options.css_class_prefix + "-results-container";
here.container.setAttribute("aria-hidden", "true"); // Screenreader is provided via "input" field, so ensure results block is ignored
// Attach header element if one exists
if (typeof here.options.results_header !== "undefined") {
var header = methods.get_option_contents_as_node(here.options.results_header, true);
if (header){
here.container.appendChild(header);
}
}
// Add the results object to the container
here.container.appendChild(here.dom);
// Attach footer element if one exists
if (typeof here.options.results_footer !== "undefined") {
// Attempt to extract markup
var footer = methods.get_option_contents_as_node(here.options.results_footer, true);
if (footer){
here.container.appendChild(footer);
}
}
// Attach listeners
util.addListener(here.target, "keydown", methods.handleKeyUp);
util.addListener(here.target, "keyup", methods.handleKeyDown);
util.addListener(here.target, "focus", methods.handleFocus);
util.addListener(here.target, "blur", methods.handleBlur);
util.addListener(here.container, "blur", methods.handleBlur);
// Allows use of commands when only results are selected (if we are not linking off somewhere)
util.addListener(here.container, "blur", methods.handleKeyUp);
// Enable screen reader support
here.reader = methods.screenreaderHelper();
// Fire ready callback
if (typeof here.options.ready === "function") here.options.ready(here);
// Fire ready event
util.triggerEvent(here.target, "quickspot:ready", here);
here.ready = true;
// Make quickspot accessible via "target"
here.target.quickspot = here;
};
/**
* Attach any queued events (Both from options.events & any events added before QS had initialized its target)
*
*/
methods.attachQueuedEvents = function() {
var evt;
// Attach queued events
for (evt in eventsQueue) {
here.on(eventsQueue[evt].event, eventsQueue[evt].callback);
}
// clean up
eventsQueue = null;
// Attach any events specified options.events
if (typeof here.options.events === "object") {
for (evt in here.options.events) {
here.on(evt, here.options.events[evt]);
}
}
};
/**
* get_option_contents_as_node
*
* Takes a value and attempts to figure out what DOM node it refers to. Value can an existing DOM node, ID, or string of HTML markup - or even a callback which returns previous types
*
* @param option - value to extract in to a DOM node
* @param allow_raw_html - allow a string fo HTML to be turned in to a DOM node (if not set, the node must already exist)
*
* @return DOM Node | false
*/
methods.get_option_contents_as_node = function(option, allow_raw_html) {
var node;
// If option is a function, call as callback in order to get result
option = (typeof option === "function") ? option(here) : option;
// Is this a valid node already?
if (typeof option === "object" && option.nodeType && option.nodeType === 1){
return option;
}
// Is it a string?
if (typeof option === "string"){
// If this has no spaces, it may be an ID?
if (option.indexOf(" ") === -1){
// Remove starting #, if it has one
node = (option.indexOf("#") === 1) ? document.getElementById(option.substring(1)) : document.getElementById(option);
// if we found somthing, return it
if (node !== null){
return node;
}
}
// Okay, doesn't look like that was an ID.
// If allow_raw_html is allowed, lets just assume this was some markup in a string
// and create a node for it.
if (typeof allow_raw_html !== "undefined" && allow_raw_html){
node = document.createElement("div");
node.innerHTML = option;
return node;
}
}
// No luck? return false
return false;
};
/**
* Start screenreaderHelper for use in supported browsers
* Currently on firefox seems to fully handle this.
*
* Attaches to primary events to provide screen reader feedback.
*/
methods.screenreaderHelper = function() {
var result_text, typing;
// create screen reader element
var reader = document.createElement("div");
reader.setAttribute("aria-live", "assertive");
reader.setAttribute("aria-relevant", "additions");
reader.className = "screenreader";
reader.setAttribute("style", "position: absolute!important; clip: rect(1px 1px 1px 1px); clip: rect(1px,1px,1px,1px);");
// Add to DOM
here.target.parentNode.appendChild(reader);
// When user finishes typing, read result status
util.addListener(here.target, "quickspot:end", function(){
if (typing) clearTimeout(typing);
typing = setTimeout(function(){
if (here.results.length === 0) {
methods.screenReaderAnnounce("No suggestions found. Hit enter to search.");
} else {
result_text = here.options.screenreader_result_text(here.results[here.selectedIndex], here.selectedIndex, here);
methods.screenReaderAnnounce(here.results.length + " suggestions. " + result_text);
}
}, 400);
});
// Announce selection
util.addListener(here.target, "quickspot:select", function(){
result_text = here.options.screenreader_result_text(here.results[here.selectedIndex], here.selectedIndex, here);
methods.screenReaderAnnounce(result_text);
});
// Announce invocation of link
util.addListener(here.target, "quickspot:activate", function(){
methods.screenReaderAnnounce("Loading...");
});
return reader;
};
/**
* Get screen-reader item text from given result
* This is a default implementation & can be overwritten, returns items in format of "1. Display Name"
* The idx number refers to its position within the result set
*
* @param {Result} result
* @return {string} screen reader text
*/
methods.getScreenReaderResultText = function(result, idx) {
if (result && typeof result.qs_screenreader_text === "string") {
return (idx + 1) + ". " + result.qs_screenreader_text;
}
return (idx + 1) + ". Go to " + util.extractValue(result, here.options.display_name) + "?";
};
/**
* Refresh current quickspot result listing
* Causes quickspot to re perform the current search & redraw its output.
* Useful when applying filters to the datastore
*
* @return void
*/
methods.refresh = function() {
// Event for quickspot start (doesn't start if no search is triggered)
util.triggerEvent(here.target, "quickspot:start", here);
// Make selected index 0 again
here.selectedIndex = 0;
// Perform search, order results & render them
here.results = here.datastore.search(here.lastValue).get();
// Render
methods.render_results(here.results);
// Event for quickspot end
util.triggerEvent(here.target, "quickspot:end", here);
util.triggerEvent(here.target, "quickspot:result", here);
};
/**
* Find and display results for a given search term
*
* @param search Term to search on.
*/
methods.findResultsFor = function(search) {
// Don't search on blank
if (search === "") {
if (typeof here.options.no_search_handler === "function") {
here.options.no_search_handler(here.dom, here);
}
if (here.options.show_all_on_blank_search) {
here.showAll();
} else {
//show nothing if no value
here.results = [];
methods.hideResults();
}
return;
}
// Avoid searching if input hasn't changed.
// Just reshown what we have
if (here.lastValue === search) {
methods.showResults();
util.triggerEvent(here.target, "quickspot:result", here);
return;
}
// Update last searched value
here.lastValue = search;
// refresh list
methods.refresh();
// show results
here.showResults();
};
/**
* On: Quick-spot focus
* If box is closed, display search results again (assuming there are any)
*/
methods.handleFocus = function(event) {
if (!here.resultsVisible){
methods.findResultsFor(here.target.value);
}
};
/**
* On: Quick-spot search typed (keydown)
* Perform search
*/
methods.handleKeyDown = function(event) {
// Do nothing if its a control key
var key = event.keyCode;
if (key === 13 || key === 38 || key === 40){
return util.preventDefault(event);
}
methods.findResultsFor(here.target.value);
};
/**
* On: Quick-spot search typed (keyup)
* Handle specal actions (enter/up/down keys)
*/
methods.handleKeyUp = function(event) {
var key = event.keyCode;
if (key === 13){ //enter
methods.handleSelection(here.results[here.selectedIndex]);
}
if (key === 38 && here.results.length !== 0){ //up
methods.selectIndex(here.selectedIndex - 1);
methods.scrollResults("up");
util.triggerEvent(here.target, "quickspot:select", here);
}
if (key === 40 && here.results.length !== 0){ // down
methods.selectIndex(here.selectedIndex + 1);
methods.scrollResults("down");
util.triggerEvent(here.target, "quickspot:select", here);
}
// prevent default action
if (key === 13 || key === 38 || key === 40){
util.preventDefault(event);
}
};
/**
* On: Quick-spot click off (blur)
* If it wasn't one of results that was selected, close results pane
*/
methods.handleBlur = function(event) {
// is hide on blur enabled
if (typeof here.options.hide_on_blur !== "undefined" && here.options.hide_on_blur === false){
return;
}
// While changing active elements document.activeElement will return the body.
// Wait a few ms for the new target to be correctly set so we can decided if we really
// want to close the search.
setTimeout(function(){
// So long as the new active element isn't the container, searchbox, or somthing in the quickspot container, close!
if (here.container !== document.activeElement && here.target !== document.activeElement && here.container.contains(document.activeElement) === false){
//close if target is neither results or searchbox
methods.hideResults();
}
}, 150);
};
/**
* Select index
* Set selected index for results (used to set the currently selected item)
*
* @param idx index of item to select
*/
methods.selectIndex = function(idx) {
// Deselect previously active item.
util.removeClass(here.dom.children[here.selectedIndex], "selected");
// Ensure ranges are valid for new item (fix them if not)
if (idx >= here.results.length){
here.selectedIndex = here.results.length - 1;
} else if (idx < 0) {
here.selectedIndex = 0;
} else {
here.selectedIndex = idx;
}
//Select new item
util.addClass(here.dom.children[here.selectedIndex], "selected");
};
/**
* Scroll results box to show currently selected item
*
* @param (string) direction up|down
*/
methods.scrollResults = function(direction) {
// Get basic DOM data (assume results all have same height)
var results_height = here.dom.clientHeight;
// Get current node, plus its offset & height
var current_result = here.dom.childNodes[here.selectedIndex];
var current_height = current_result.offsetHeight;
var current_offset = current_result.offsetTop;
// if we are scrolling down: If the bottom of the current item (offset+height) is below
// the displayed portion of the results (results_height), set new scroll position of container
if (direction === "down" && ((current_offset + current_height) - here.dom.scrollTop) > results_height){
here.dom.scrollTop = (current_offset + current_height) - results_height;
}
// if scrolling up: if the top elements top is above the container, scroll container to
// current elements offset position
if (direction === "up" && current_offset < here.dom.scrollTop){
here.dom.scrollTop = current_offset;
}
};
/**
* Attempt to render empty search results (no results found)
*
* @param results array
*/
methods.render_empty_results = function() {
// no results found
util.triggerEvent(here.target, "quickspot:noresults", here);
// See if we have a message to show?
var msg = here.options.no_results(here, here.lastValue);
// no "no_results" message was set
if (msg === false){
return methods.hideResults();
}
// Set message
here.dom.innerHTML = msg;
// If there is a child, connect it to the handle selector
if (typeof here.dom.childNodes[0] !== "undefined"){
util.addListener(here.dom.childNodes[0], "click", function(event){ methods.handleSelection(); });
}
// if we have a msg, make sure we show it
return methods.showResults();
};
/**
* Render search results to user
*
* @param results array
*/
methods.render_results = function(results) {
// Manipulate result array before render?
if (typeof here.options.parse_results === "function"){
results = here.options.parse_results(results, here.options);
}
// If no results, don"t show result box.
if (results.length === 0){
return methods.render_empty_results();
}
// If we have results, append required items in to a documentFragment (to avoid unnecessary DOM reflows that will slow this down)
var fragment = document.createDocumentFragment();
var tmp, result_str, classes; // reuse object, JS likes this
// if max_results is provided, slice off unwanted results (0 = show all, don"t bother slicing if array is smaller than maxResults)
if (typeof here.options.max_results === "number" && here.options.max_results !== 0 && results.length > here.options.max_results) {
results = results.slice(0, here.options.max_results);
}
// For each item (given there own scope by this method)
results.forEach(function(result, idx){
// Set name/title
if (typeof here.options.display_handler === "function"){
result_str = here.options.display_handler(result, here);
} else {
result_str = util.extractValue(result, here.options.display_name);
}
// Automatically highlight matching portion of text
if (here.options.auto_highlight === true) {
// Attempt to avoid sticking strong"s in the middle of html tags
// http://stackoverflow.com/questions/18621568/regex-replace-text-outside-html-tags#answer-18622606
result_str = result_str.replace(RegExp("(" + here.lastValue + ")(?![^<]*>|[^<>]*<\/)", "i"), "<strong>$1</strong>");
}
// Create new a element
tmp = document.createElement("a");
tmp.innerHTML = result_str;
// Apply classes
classes = here.options.css_class_prefix + "-result " + here.options.css_class_prefix + "-result-" + idx;
if (typeof result.qs_result_class === "string"){
classes = result.qs_result_class + " " + classes;
}
tmp.className = classes;
// Attach listener (click)
util.addListener(tmp, "click", function(event) {
methods.handleSelection(result);
});
// Attach listener (hover)
util.addListener(tmp, "mouseover", function(event) {
methods.selectIndex(idx);
});
// Add to fragment
fragment.appendChild(tmp);
});
//event when results found
util.triggerEvent(here.target, "quickspot:resultsfound", here);
// Clear old data from DOM.
here.dom.innerHTML = "";
// Attach fragment
here.dom.appendChild(fragment);
// Select the initial value.
methods.selectIndex(here.selectedIndex);
};
/**
* handleSelection handles action from click (or enter key press)
*
* Depending on settings will either send user to url, update box this is attached to or
* perform action specified in click_handler if it is set.
*
* @param result object defining selected result
*
*/
methods.handleSelection = function(result) {
// Ensure result is valid
if (typeof result === "undefined") return here.options.no_results_click(here.lastValue, here);
// Fire activate event
util.triggerEvent(here.target, "quickspot:activate", here);
// If custom handler was provided
if (typeof here.options.click_handler !== "undefined"){
here.options.click_handler(result, here);
} else {
if (typeof result.url === "string"){
// If URL was provided, go there
window.location.href = result.url;
} else {
// else assume we are just a typeahead?
here.target.value = util.extractValue(result, here.options.display_name);
methods.hideResults();
}
}
};
/**
* handle no results
*
* @param self - ref to quickspot instance
* @param search - search term
*/
methods.no_results = function(self, search) {
return "<a class=\"" + here.options.css_class_prefix + "-result selected\">No results...</a>";
};
/**
* screenreader: Read tex
* Write text to announce block. This makes screenreader read provided text to the user
*
* @param text - text to read
*/
methods.screenReaderAnnounce = function(text) {
here.reader.innerHTML = "<p>" + text + "</p>"; // must be wrapped in p to trigger correct behavior
};
/**
* Initialise data
* create a new datastore to allow quick access, filtering and searching of provided data.
*
* @param data raw json
*/
methods.initialise_data = function(data) {
// Set datastore
here.setDatastore( datastore.create(data, here.options) );
// Fire loaded event
util.triggerEvent(here.target, "quickspot:init", here);
};
/**
* Hide QS results
*/
methods.hideResults = function() {
util.triggerEvent(here.target, "quickspot:hideresults", here);
if (typeof here.options.hide_results === "function"){
here.options.hide_results(here.container, here);
} else {
here.container.style.display = "none";
}
// hide is complete
here.resultsVisible = false;
};
/**
* Show QS results
*/
methods.showResults = function() {
util.triggerEvent(here.target, "quickspot:showresults", here);
if (typeof here.options.show_results === "function"){
here.options.show_results(here.container, here);
} else {
here.container.style.display = "block";
}
// show is complete
here.resultsVisible = true;
};
// Default configurtion
this.options = {
"key_value": "name",
"css_class_prefix": "quickspot",
"auto_highlight": true,
"show_all_on_blank_search": false,
"no_results": methods.no_results,
"screenreader_result_text": methods.getScreenReaderResultText,
"no_results_click": function(val, sbox){},
"error" : function(http_status, data) { console.log("[Quickspot] AJAX request failed with error " + http_status);}
};
};
/**
* Datastore component.
* datastore components are created with each quickspot instance & provide all the mechanisms for quickly
* searching, filtering and ordering the data.
*
* @param data to store (array of objects)
* @param options/settings
* - search_on: columns to search on
* - key_value: primary value (weighted for results ordering)
* - gen_score: function to score objects by closeness to string, used for sorting
*/
var datastore = function(data, options) {
// internal datastores
this.data = [];
this.data_filtered = [];
this.results = [];
// Accesser to primary "this" for internal objs
var here = this;
// private methods
var ds = {};
/**
* Create
*
* Create a new datastore instance. The datastore will use the data and options to generate
* an internal pre-proccessed representation of the data in order to allow quicker searching
* and filtering
*
* @param data raw JSON
* @param options
*/
ds.create = function(data, options) {
// Merge passed in options into options obj
for (var i in options){
here.options[i] = options[i];
}
// If no key value, use name.
if (!here.options.key_value){
here.options.key_value = "name";
}
// Use a standard alphabetical string sort if one isn't provided
// Accept `false` to remove the default sort entirely
if (typeof here.options.default_sort !== "function" &&
here.options.default_sort !== false) {
here.options.default_sort = ds.default_sort;
}
here.fill(data);
};
/**
* Empty datastore of all values
*/
this.empty = function() {
this.data_filtered = this.data = this.results = [];
return this;
};
/**
* import a new data set
*/
this.fill = function(data) {
// Pre-parse data (re arrange structure to allow searching if needed)
if (typeof here.options.data_pre_parse === "function") {
data = here.options.data_pre_parse(data, here.options);
}
// Convert object to array if found
// keys will be thrown away
if (!data instanceof Array){
var tmp = [], i;
for (i in data){
if (data.hasOwnProperty(i) && typeof data[i] === "object" && data[i] !== null) {
tmp.push(data[i]);
}
}
data = tmp;
}
var attrs = (typeof here.options.search_on !== "undefined") ? here.options.search_on : false;
// Loop through searchable items, adding all values that will need to be searched upon in to a
// string stored as __searchvalues. Either add everything or just what the user specifies.
for (i = 0; i < data.length; i++) {
// If search_on exists use th as attributes list, else just use all of them
data[i] = ds.pre_process(data[i], attrs);
}
// Store in memory
here.data_filtered = here.data = util.shallowCopy(data);
here.results = [];
return this;
};
/**
* find
* Find any results where search string is within the objects searchable columns
*
* @param search string
* @param col - only look for string in given column
* @return this
*/
this.find = function(search, col) {
search = here.options.string_filter(search);
if (here.options.allow_partial_matches === true) {
here.results = util.shallowCopy(this.data_filtered);
search.split(" ").forEach(function(term) {
here.results = ds.find(term, here.results, col);
});
} else {
this.results = ds.find(search, this.data_filtered, col);
}
return this;
};
/**
* sort results by $str
* sort results by closeness to provided string
*
* @param search string | sort function
* @return this
*/
this.sort_results_by = function(search) {
if (typeof search === "function") {
// sort by a custom function?
this.results.sort(search);
} else if (search === false || here.options.default_sort === false && search === "") {