1
/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
3
YUI.add('lazr.picker', function(Y) {
6
* Module containing the Lazr searchable picker.
13
* A picker is a pop-up widget containing a search field and displaying a list
17
* @extends lazr.PrettyOverlay
21
var PICKER = 'picker',
22
BOUNDING_BOX = 'boundingBox',
23
CONTENT_BOX = 'contentBox',
26
getCN = Y.ClassNameManager.getClassName,
29
C_SEARCH = getCN(PICKER, 'search'),
30
C_SEARCH_BOX = getCN(PICKER, 'search-box'),
31
C_SEARCH_SLOT = getCN(PICKER, 'search-slot'),
32
C_FOOTER_SLOT = getCN(PICKER, 'footer-slot'),
33
C_SEARCH_MODE = getCN(PICKER, 'search-mode'),
34
C_RESULTS = getCN(PICKER, 'results'),
35
C_RESULT_TITLE = getCN(PICKER, 'result-title'),
36
C_RESULT_DESCRIPTION = getCN(PICKER, 'result-description'),
37
C_ERROR = getCN(PICKER, 'error'),
38
C_ERROR_MODE = getCN(PICKER, 'error-mode'),
39
C_NO_RESULTS = getCN(PICKER, 'no-results'),
40
C_BATCHES = getCN(PICKER, 'batches'),
41
C_SELECTED_BATCH = getCN(PICKER, 'selected-batch'),
48
MIN_SEARCH_CHARS = 'min_search_chars',
49
CURRENT_SEARCH_STRING = 'current_search_string',
53
BATCH_COUNT = 'batch_count',
54
SEARCH_SLOT = 'search_slot',
55
FOOTER_SLOT = 'footer_slot',
56
SELECTED_BATCH = 'selected_batch',
57
SEARCH_MODE = 'search_mode',
58
NO_RESULTS_SEARCH_MESSAGE = 'no_results_search_message',
59
RENDERUI = "renderUI",
64
var Picker = function () {
65
Picker.superclass.constructor.apply(this, arguments);
67
Y.after(this._renderUIPicker, this, RENDERUI);
68
Y.after(this._bindUIPicker, this, BINDUI);
69
Y.after(this._syncUIPicker, this, BINDUI);
72
Y.extend(Picker, Y.lazr.PrettyOverlay, {
74
* The search input box node.
76
* @property _search_input
83
* The search button node.
85
* @property _search_button
92
* The node containing search results.
94
* @property _results_box
101
* The node containing the extra form inputs.
103
* @property _search_slot_box
107
_search_slot_box: null,
110
* The node containing the batches.
112
* @property _batches_box
119
* The node containing the previous batch button.
121
* @property _prev_button
128
* The node containing the next batch button.
130
* @property _next_button
137
* The node containing an error message if any.
139
* @property _error_box
145
initializer: function(cfg) {
147
* Fires when the user presses the 'Search' button.
148
* The event details contain the search string entered by the user.
150
* This event is only fired if the search string is longer than the
151
* min_search_chars attribute.
153
* This event is also fired when the user clicks on one of the batch
154
* items, the details then contain both the previous search string and
155
* the value of the batch item selected.
158
* @preventable _defaultSearch
160
this.publish(SEARCH, { defaultFn: this._defaultSearch });
163
* Fires when the user selects one of the result. The event details
164
* contain the value of the selected result.
167
* @preventable _defaultSave
169
this.publish(SAVE, { defaultFn: this._defaultSave } );
171
// Subscribe to the cancel event so that we can clear the widget when
173
this.subscribe('cancel', this._defaultCancel);
175
if ( this.get('picker_activator') ) {
176
var element = Y.one(this.get('picker_activator'));
177
element.on('click', function(e) {
181
element.addClass(this.get('picker_activator_css_class'));
187
* Update the container for extra form inputs.
189
* @method _syncSearchSlotUI
192
_syncSearchSlotUI: function() {
193
var search_slot = this.get(SEARCH_SLOT);
195
// Clear previous slot contents.
196
this._search_slot_box.set('innerHTML', '');
198
if (search_slot !== null) {
199
this._search_slot_box.appendChild(search_slot);
204
* Update the container for extra form inputs.
206
* @method _syncSearchSlotUI
209
_syncFooterSlotUI: function() {
210
var footer_slot = this.get(FOOTER_SLOT);
212
// Clear previous slot contents.
213
this._footer_slot_box.set('innerHTML', '');
215
if (footer_slot !== null) {
216
this._footer_slot_box.appendChild(footer_slot);
221
* Return the batch page information.
223
* @method _getBatches
226
_getBatches: function() {
227
var batches = this.get(BATCHES);
229
if (batches === null) {
230
var batch_count = this.get(BATCH_COUNT);
231
if (batch_count === null) {
236
// Only create batch pages when there's more than one.
237
if (batch_count > 1) {
238
for (var i = 0; i < batch_count; i++) {
239
batches.push({ value: i, name: i + 1 });
248
* Update the batches container in the UI.
250
* @method _syncBatchesUI
253
_syncBatchesUI: function() {
254
var batches = this._getBatches();
256
// Clear previous batches.
257
Y.Event.purgeElement(this._batches_box, true);
258
this._batches_box.set('innerHTML', '');
260
if (batches.length === 0) {
261
this._prev_button = null;
262
this._next_button = null;
266
// The enabled property of the prev/next buttons is controlled
267
// in _syncSelectedBatchUI.
268
this._prev_button = Y.Node.create(Y.lazr.ui.PREVIOUS_BUTTON);
269
this._prev_button.on('click', function (e) {
270
var selected = this.get(SELECTED_BATCH) - 1;
271
this.set(SELECTED_BATCH, selected);
273
SEARCH, this.get(CURRENT_SEARCH_STRING),
274
batches[selected].value);
276
this._batches_box.appendChild(this._prev_button);
278
Y.Array.each(batches, function(data, i) {
279
var batch_item = Y.Node.create('<span></span>');
280
batch_item.appendChild(
281
document.createTextNode(data.name));
282
this._batches_box.appendChild(batch_item);
284
batch_item.on('click', function (e) {
285
this.set(SELECTED_BATCH, i);
287
SEARCH, this.get(CURRENT_SEARCH_STRING), data.value);
291
this._next_button = Y.Node.create(Y.lazr.ui.NEXT_BUTTON);
292
this._batches_box.appendChild(this._next_button);
293
this._next_button.on('click', function (e) {
294
var selected = this.get(SELECTED_BATCH) + 1;
295
this.set(SELECTED_BATCH, selected);
297
SEARCH, this.get(CURRENT_SEARCH_STRING),
298
batches[selected].value);
303
* Synchronize the selected batch with the UI.
305
* @method _syncSelectedBatchUI
308
_syncSelectedBatchUI: function() {
309
var idx = this.get(SELECTED_BATCH);
310
var items = this._batches_box.all('span');
312
this._prev_button.set('disabled', idx === 0);
313
items.removeClass(C_SELECTED_BATCH);
314
items.item(idx).addClass(C_SELECTED_BATCH);
315
this._next_button.set('disabled', idx+1 === items.size());
320
* Return a node containing the specified text. If a href is provided,
321
* then the text will be linkified with with the given css class. The
322
* link will open in a new window (but the browser can be configured to
323
* open a new tab instead if the user so wishes).
324
* @param text the text to render
325
* @param href the URL of the new window
326
* @param css the style to use when rendering the link
328
_text_or_link: function(text, href, css) {
331
result=Y.Node.create('<a></a>').addClass(css);
332
result.set('text', text).set('href', href);
333
Y.on('click', function(e) {
338
result = document.createTextNode(text);
344
* Render a node containing the title part of the picker entry.
345
* The title will consist of some main text with some optional alternate
346
* text which will be rendered in parentheses after the main text. The
347
* title/alt_title text may separately be turned into a link with user
349
* @param data a json data object with the details to render
351
_renderTitleUI: function(data) {
352
var li_title = Y.Node.create(
353
'<span></span>').addClass(C_RESULT_TITLE);
354
var title = this._text_or_link(
355
data.title, data.title_link, data.link_css);
356
li_title.appendChild(title);
357
if (data.alt_title) {
358
var alt_title = this._text_or_link(
359
data.alt_title, data.alt_title_link, data.link_css);
360
li_title.appendChild(' (');
361
li_title.appendChild(alt_title);
362
li_title.appendChild(')');
368
* Render a node containing the badges part of the picker entry.
369
* The badges are small images which are displayed next to the title. The
370
* display of badges is optional.
371
* @param data a json data object with the details to render
373
_renderBadgesUI: function(data) {
375
var badges = Y.Node.create('<div></div>').addClass('badge');
376
Y.each(data.badges, function(badge_info) {
377
var badge_url = badge_info.url;
378
var badge_alt = badge_info.alt;
379
var badge = Y.Node.create('<img></img>')
381
.set('src', badge_url)
382
.set('alt', badge_alt);
383
badges.appendChild(badge);
391
* Render a node containing the description part of the picker entry.
392
* @param data a json data object with the details to render
394
_renderDescriptionUI: function(data) {
395
var li_desc = Y.Node.create(
396
'<div><br /></div>').addClass(C_RESULT_DESCRIPTION);
397
if (data.description) {
398
li_desc.replaceChild(
399
document.createTextNode(data.description),
406
* Update the UI based on the results attribute.
408
* @method _syncResultsUI
411
_syncResultsUI: function() {
412
var results = this.get(RESULTS);
414
// Remove any previous results.
415
Y.Event.purgeElement(this._results_box, true);
416
this._results_box.set('innerHTML', '');
418
Y.Array.each(results, function(data, i) {
419
// Sort out the badges div.
420
var li_badges = this._renderBadgesUI(data);
421
// Sort out the title span.
422
var li_title = this._renderTitleUI(data);
423
// Sort out the description div.
424
var li_desc = this._renderDescriptionUI(data);
425
// Put the list item together.
426
var li = Y.Node.create('<li></li>').addClass(
427
i % 2 ? Y.lazr.ui.CSS_ODD : Y.lazr.ui.CSS_EVEN);
429
li.addClass(data.css);
433
Y.Node.create('<img />').set('src', data.image));
435
if (li_badges !== null)
436
li.appendChild(li_badges);
437
li.appendChild(li_title);
438
li.appendChild(li_desc);
440
li.on('click', function (e, value) {
441
this.fire(SAVE, value);
444
this._results_box.appendChild(li);
447
// If the user has entered a search and there ain't no results,
448
// display the message about no items matching.
449
if (this._search_input.get('value') && !results.length) {
450
var msg = Y.Node.create('<li></li>');
452
document.createTextNode(
453
Y.substitute(this.get(NO_RESULTS_SEARCH_MESSAGE),
454
{query: this._search_input.get('value')})));
455
this._results_box.appendChild(msg);
456
this._results_box.addClass(C_NO_RESULTS);
458
this._results_box.removeClass(C_NO_RESULTS);
461
if (results.length) {
462
// Set PrettyOverlay's green progress bar to 100%.
463
this.set('progress', 100);
465
// Set PrettyOverlay's green progress bar to 50%.
466
this.set('progress', 50);
471
* Sync UI with search mode. Disable the search input and button.
473
* @method _syncSearchModeUI
476
_syncSearchModeUI: function() {
477
var search_mode = this.get(SEARCH_MODE);
478
this._search_input.set('disabled', search_mode);
479
this._search_button.set('disabled', search_mode);
481
this.get(BOUNDING_BOX).addClass(C_SEARCH_MODE);
483
this.get(BOUNDING_BOX).removeClass(C_SEARCH_MODE);
484
// If the search input isn't blurred before it is focused,
485
// then the I-beam disappears.
486
this._search_input.blur();
487
this._search_input.focus();
492
* Sync UI with the error message.
494
* @method _syncErrorUI
497
_syncErrorUI: function() {
498
var error = this.get(ERROR);
499
this._error_box.set('innerHTML', '');
500
if (error === null) {
501
this.get(BOUNDING_BOX).removeClass(C_ERROR_MODE);
503
this._error_box.appendChild(document.createTextNode(error));
504
this.get(BOUNDING_BOX).addClass(C_ERROR_MODE);
509
* Create the widget's HTML components.
511
* This method is invoked after renderUI is invoked for the Widget class
512
* using YUI's aop infrastructure.
515
* @method _renderUIPicker
518
_renderUIPicker: function() {
519
this._search_button = Y.Node.create(Y.lazr.ui.SEARCH_BUTTON);
521
var search_box = Y.Node.create([
523
'<input type="text" size="20" name="search" ',
524
'autocomplete="off"/>',
525
'<div></div></div>'].join(""));
527
this._search_input = search_box.one('input');
528
this._search_input.addClass(C_SEARCH);
530
this._error_box = search_box.one('div');
531
this._error_box.addClass(C_ERROR);
533
// The search button is floated right to avoid problems with
534
// the input width in Safari 3.
535
search_box.insertBefore(this._search_button, this._search_input);
536
search_box.addClass(C_SEARCH_BOX);
538
this._search_slot_box = Y.Node.create('<div></div');
539
this._search_slot_box.addClass(C_SEARCH_SLOT);
540
search_box.appendChild(this._search_slot_box);
542
this._results_box = Y.Node.create('<ul></ul>');
543
this._results_box.addClass(C_RESULTS);
545
this._batches_box = Y.Node.create('<div></div');
546
this._batches_box.addClass(C_BATCHES);
548
this._footer_slot_box = Y.Node.create('<div></div');
549
this._footer_slot_box.addClass(C_FOOTER_SLOT);
551
var body = Y.Node.create('<div></div>');
552
body.appendChild(search_box);
553
body.appendChild(this._batches_box);
554
body.appendChild(this._results_box);
555
body.appendChild(this._footer_slot_box);
556
body.addClass('yui3-widget-bd');
558
this.setStdModContent(Y.WidgetStdMod.BODY, body, Y.WidgetStdMod.APPEND);
562
* Bind the widget's DOM elements to their event handlers.
564
* This method is invoked after bindUI is invoked for the Widget class
565
* using YUI's aop infrastructure.
568
* @method _bindUIPicker
571
_bindUIPicker: function() {
572
Y.on('click', this._defaultSearchUserAction, this._search_button,
577
'key', this._defaultSearchUserAction, this._search_input,
580
// Focus search box when the widget is first displayed.
581
this.after('visibleChange', function (e) {
582
var change = e.details[0];
583
if (change.newVal === true && change.prevVal === false) {
584
// The widget has to be centered before the search
585
// input is focused, so that it is centered in the current
586
// viewport and not the viewport after scrolling to the
588
this.set('centered', true);
589
this._search_input.focus();
593
// Update the display whenever the "results" property is changed and
594
// clear the search mode.
595
this.after('resultsChange', function (e) {
596
this._syncResultsUI();
597
this.set(SEARCH_MODE, false);
600
// Update the search slot box whenever the "search_slot" property
602
this.after('search_slotChange', function (e) {
603
this._syncSearchSlotUI();
606
// Update the footer slot box whenever the "footer_slot" property
608
this.after('footer_slotChange', function (e) {
609
this._syncFooterSlotUI();
612
// Update the batch list whenever the "batches" or "results" property
614
var doBatchesChange = function (e) {
615
this._syncBatchesUI();
616
this._syncSelectedBatchUI();
619
this.after('batchesChange', doBatchesChange, this);
620
this.after('resultsChange', doBatchesChange, this);
622
// Keep the UI in sync with the currently selected batch.
623
this.after('selected_batchChange', function (e) {
624
this._syncSelectedBatchUI();
627
// Update the display whenever the "results" property is changed.
628
this.after('search_modeChange', function (e) {
629
this._syncSearchModeUI();
632
// Update the display whenever the "error" property is changed.
633
this.after('errorChange', function (e) {
639
* Synchronize the search box, error message and results with the UI.
641
* This method is invoked after syncUI is invoked for the Widget class
642
* using YUI's aop infrastructure.
645
* @method _syncUIPicker
648
_syncUIPicker: function() {
649
this._syncResultsUI();
650
this._syncSearchModeUI();
651
this._syncBatchesUI();
652
this._syncSelectedBatchUI();
654
this._search_input.focus();
658
* Clear all elements of the picker, resetting it to its original state.
661
* @param e {Object} The event object.
665
this.set(CURRENT_SEARCH_STRING, '');
667
this.set(RESULTS, [{}]);
668
this.set(BATCHES, null);
669
this.set(BATCH_COUNT, null);
670
this._search_input.set('value', '');
671
this._results_box.set('innerHTML', '');
675
* Handle clicks on the 'Search' button or entering the enter key in the
676
* search field. This fires the search event.
678
* @method _defaultSearchUserAction
679
* @param e {Event.Facade} An Event Facade object.
682
_defaultSearchUserAction: function(e) {
684
var search_string = Y.Lang.trim(this._search_input.get('value'));
685
if (search_string.length < this.get(MIN_SEARCH_CHARS)) {
686
var msg = Y.substitute(
687
"Please enter at least {min} characters.",
688
{min: this.get(MIN_SEARCH_CHARS)});
689
this.set(ERROR, msg);
691
this.set(CURRENT_SEARCH_STRING, search_string);
692
this.fire(SEARCH, search_string);
697
* By default, the search event puts the widget in search mode. It also
698
* clears the error, if there is any.
700
* @method _defaultSearch
701
* @param e {Event.Facade} An Event Facade object.
704
_defaultSearch: function(e) {
705
this.set(ERROR, null);
706
this.set(SEARCH_MODE, true);
710
* By default, the cancel event just hides the widget, but you can
711
* have it also cleared by setting clear_on_cancel to 'true'.
713
* @method _defaultCancel
714
* @param e {Event.Facade} An Event Facade object.
717
_defaultCancel : function(e) {
718
Picker.superclass._defaultCancel.apply(this, arguments);
719
if ( this.get('clear_on_cancel') ) {
725
* By default, the save event clears and hides the widget, but you can
726
* have it not cleared by setting clear_on_save to 'false'. The search
727
* entered by the user is passed in the first details attribute of the
730
* @method _defaultSave
731
* @param e {Event.Facade} An Event Facade object.
734
_defaultSave : function(e) {
736
if ( this.get('clear_on_save') ) {
742
* By default, the select-batch event turns on search-mode.
744
* @method _defaultSelectBatch
745
* @param e {Event.Facade} An Event Facade object.
748
_defaultSelectBatch: function(e) {
749
this.set(SEARCH_MODE, true);
753
Picker.NAME = PICKER;
756
* The details index of the save result.
759
* @property SAVE_RESULT
761
Picker.SAVE_RESULT = 0;
764
* The details index of the search string.
767
* @property SEARCH_STRING
769
Picker.SEARCH_STRING = 0;
772
* The details index of the selected batch value.
775
* @property SELECTED_BATCH_VALUE
777
Picker.SELECTED_BATCH_VALUE = 1;
782
* Whether or not the search box and result list should be cleared when
783
* the save event is fired.
785
* @attribute clear_on_save
788
clear_on_save: { value: true },
791
* Whether or not the search box and result list should be cleared when
792
* the cancel event is fired.
794
* @attribute clear_on_cancel
797
clear_on_cancel: { value: false },
800
* A CSS selector for the DOM element that will activate (show) the picker
803
* @attribute picker_activator
806
picker_activator: { value: null },
809
* An extra CSS class to be added to the picker_activator, generally used
810
* to distinguish regular links from js-triggering ones.
812
* @attribute picker_activator_css_class
815
picker_activator_css_class: { value: 'js-action' },
818
* Minimum number of characters that need to be entered in the search
819
* string input before a search event will be fired. The search string
820
* will be trimmed before testing the length.
822
* @attribute min_search_chars
825
min_search_chars: { value: 3 },
828
* The current search string, which is needed when clicking on a different
829
* batch if the search input has been modified.
831
* @attribute current_search_string
834
current_search_string: {value: ''},
837
* Results currently displayed by the widget. Updating this value
838
* automatically updates the display.
843
results: { value: [] },
846
* This adds any form fields you want below the search field.
847
* Updating this value automatically updates the display, but only
848
* if the widget has already been rendered. Otherwise, the change
851
* @attribute search_slot
854
search_slot: {value: null},
857
* A place for custom html at the bottom of the widget. When there
858
* are no search results the search_slot and the footer_slot are
859
* right next to each other.
860
* Updating this value automatically updates the display, but only
861
* if the widget has already been rendered. Otherwise, the change
864
* @attribute footer_slot
867
footer_slot: {value: null},
870
* Batches currently displayed in the widget, which can be
871
* clicked to change the batch of results being displayed. Updating
872
* this value automatically updates the display.
874
* This an array of object containing the two keys, name (used as
875
* the batch label) and value (used as additional details to SEARCH
881
batches: {value: null},
884
* For simplified batch creation, you can set this to the number of
885
* batches in the search results. In this case, the batch labels
886
* and values are automatically calculated. The batch name (used as the
887
* batch label) will be the batch number starting from 1. The batch value
888
* (used as additional details to the SEARCH event) will be the batch
889
* number, starting from zero.
891
* If 'batches' is set (see above), batch_count is ignored.
893
* @attribute batch_count
896
batch_count: {value: null},
899
* Batch currently selected.
901
* @attribute selected_batch
906
getter: function (value) {
909
validator: function (value) {
910
var batches = this._getBatches();
911
return Y.Lang.isNumber(value) &&
913
value < batches.length;
917
* Flag indicating if the widget is currently in search mode (so users
918
* has triggered a search and we are waiting for results.)
920
* @attribute search_mode
923
search_mode: { value: false },
926
* The current error message. This puts the widget in 'error-mode',
927
* setting this value to null clears that state.
932
error: { value: null },
935
* The message to display when the search returned no results. This string
936
* can contain a 'query' placeholder
938
* @attribute no_results_search_message
940
* @default No items matched "{query}".
942
no_results_search_message: {
943
value: 'No items matched "{query}".'
949
* This plugin is used to associate a picker instance to an input element of
950
* the DOM. When the picker is shown, it takes its initial value from that
951
* element and when the save event is fired, the value of the chosen item
952
* (from the picker's list of results) is copied to that element.
954
* Also, this plugin expects a single attribute (input_element) in the
955
* config passed to its constructor, which defines the element that will be
956
* associated with the picker.
958
* @class TextFieldPickerPlugin
959
* @extends Y.Plugin.Base
963
function TextFieldPickerPlugin(config) {
964
TextFieldPickerPlugin.superclass.constructor.apply(this, arguments);
967
TextFieldPickerPlugin.NAME = 'TextFieldPickerPlugin';
968
TextFieldPickerPlugin.NS = 'txtpicker';
970
Y.extend(TextFieldPickerPlugin, Y.Plugin.Base, {
971
initializer: function(config) {
972
var input = Y.one(config.input_element);
973
this.doAfter('save', function (e) {
974
var result = e.details[Y.lazr.Picker.SAVE_RESULT];
975
input.set("value", result.value);
976
// If the search input isn't blurred before it is focused,
977
// then the I-beam disappears.
981
this.doAfter('show', function() {
982
if ( input.get("value") ) {
983
this.get('host')._search_input.set('value', input.get("value"));
989
Y.lazr.Picker = Picker;
990
Y.lazr.TextFieldPickerPlugin = TextFieldPickerPlugin;
992
}, "0.1", {"skinnable": true,
993
"requires": ["oop", "event", "event-focus", "node", "plugin",
994
"substitute", "widget", "widget-stdmod",
995
"lazr.overlay", "lazr.anim", "lazr.base"]