~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/app/javascript/lazr/picker/picker.js

[r=deryck][bug=803954] Bring lazr-js source into lp tree and package
        yui as a dependency

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
/* Copyright (c) 2009, Canonical Ltd. All rights reserved. */
 
2
 
 
3
YUI.add('lazr.picker', function(Y) {
 
4
 
 
5
/**
 
6
 * Module containing the Lazr searchable picker.
 
7
 *
 
8
 * @module lazr.picker
 
9
 * @namespace lazr
 
10
 */
 
11
 
 
12
/**
 
13
 * A picker is a pop-up widget containing a search field and displaying a list
 
14
 * of found results.
 
15
 *
 
16
 * @class Picker
 
17
 * @extends lazr.PrettyOverlay
 
18
 * @constructor
 
19
 */
 
20
 
 
21
var PICKER  = 'picker',
 
22
    BOUNDING_BOX = 'boundingBox',
 
23
    CONTENT_BOX  = 'contentBox',
 
24
 
 
25
    // Local aliases
 
26
    getCN = Y.ClassNameManager.getClassName,
 
27
 
 
28
    // CSS Classes
 
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'),
 
42
 
 
43
    // Events
 
44
    SAVE = 'save',
 
45
    SEARCH = 'search',
 
46
 
 
47
    // Property constants
 
48
    MIN_SEARCH_CHARS = 'min_search_chars',
 
49
    CURRENT_SEARCH_STRING = 'current_search_string',
 
50
    ERROR = 'error',
 
51
    RESULTS = 'results',
 
52
    BATCHES = 'batches',
 
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",
 
60
    BINDUI = "bindUI",
 
61
    SYNCUI = "syncUI";
 
62
 
 
63
 
 
64
var Picker = function () {
 
65
    Picker.superclass.constructor.apply(this, arguments);
 
66
 
 
67
    Y.after(this._renderUIPicker, this, RENDERUI);
 
68
    Y.after(this._bindUIPicker, this, BINDUI);
 
69
    Y.after(this._syncUIPicker, this, BINDUI);
 
70
};
 
71
 
 
72
Y.extend(Picker, Y.lazr.PrettyOverlay, {
 
73
    /**
 
74
     * The search input box node.
 
75
     *
 
76
     * @property _search_input
 
77
     * @type Node
 
78
     * @private
 
79
     */
 
80
    _search_input: null,
 
81
 
 
82
    /**
 
83
     * The search button node.
 
84
     *
 
85
     * @property _search_button
 
86
     * @type Node
 
87
     * @private
 
88
     */
 
89
    _search_button: null,
 
90
 
 
91
    /**
 
92
     * The node containing search results.
 
93
     *
 
94
     * @property _results_box
 
95
     * @type Node
 
96
     * @private
 
97
     */
 
98
    _results_box: null,
 
99
 
 
100
    /**
 
101
     * The node containing the extra form inputs.
 
102
     *
 
103
     * @property _search_slot_box
 
104
     * @type Node
 
105
     * @private
 
106
     */
 
107
    _search_slot_box: null,
 
108
 
 
109
    /**
 
110
     * The node containing the batches.
 
111
     *
 
112
     * @property _batches_box
 
113
     * @type Node
 
114
     * @private
 
115
     */
 
116
    _batches_box: null,
 
117
 
 
118
    /**
 
119
     * The node containing the previous batch button.
 
120
     *
 
121
     * @property _prev_button
 
122
     * @type Node
 
123
     * @private
 
124
     */
 
125
    _prev_button: null,
 
126
 
 
127
    /**
 
128
     * The node containing the next batch button.
 
129
     *
 
130
     * @property _next_button
 
131
     * @type Node
 
132
     * @private
 
133
     */
 
134
    _next_button: null,
 
135
 
 
136
    /**
 
137
     * The node containing an error message if any.
 
138
     *
 
139
     * @property _error_box
 
140
     * @type Node
 
141
     * @private
 
142
     */
 
143
    _error_box: null,
 
144
 
 
145
    initializer: function(cfg) {
 
146
        /**
 
147
         * Fires when the user presses the 'Search' button.
 
148
         * The event details contain the search string entered by the user.
 
149
         *
 
150
         * This event is only fired if the search string is longer than the
 
151
         * min_search_chars attribute.
 
152
         *
 
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.
 
156
         *
 
157
         * @event search
 
158
         * @preventable _defaultSearch
 
159
         */
 
160
        this.publish(SEARCH, { defaultFn: this._defaultSearch });
 
161
 
 
162
        /**
 
163
         * Fires when the user selects one of the result. The event details
 
164
         * contain the value of the selected result.
 
165
         *
 
166
         * @event save
 
167
         * @preventable _defaultSave
 
168
         */
 
169
        this.publish(SAVE, { defaultFn: this._defaultSave } );
 
170
 
 
171
        // Subscribe to the cancel event so that we can clear the widget when
 
172
        // requested.
 
173
        this.subscribe('cancel', this._defaultCancel);
 
174
 
 
175
        if ( this.get('picker_activator') ) {
 
176
            var element = Y.one(this.get('picker_activator'));
 
177
            element.on('click', function(e) {
 
178
                e.halt();
 
179
                this.show();
 
180
            }, this);
 
181
            element.addClass(this.get('picker_activator_css_class'));
 
182
        }
 
183
 
 
184
    },
 
185
 
 
186
    /**
 
187
     * Update the container for extra form inputs.
 
188
     *
 
189
     * @method _syncSearchSlotUI
 
190
     * @protected
 
191
     */
 
192
    _syncSearchSlotUI: function() {
 
193
        var search_slot = this.get(SEARCH_SLOT);
 
194
 
 
195
        // Clear previous slot contents.
 
196
        this._search_slot_box.set('innerHTML', '');
 
197
 
 
198
        if (search_slot !== null) {
 
199
            this._search_slot_box.appendChild(search_slot);
 
200
        }
 
201
    },
 
202
 
 
203
    /**
 
204
     * Update the container for extra form inputs.
 
205
     *
 
206
     * @method _syncSearchSlotUI
 
207
     * @protected
 
208
     */
 
209
    _syncFooterSlotUI: function() {
 
210
        var footer_slot = this.get(FOOTER_SLOT);
 
211
 
 
212
        // Clear previous slot contents.
 
213
        this._footer_slot_box.set('innerHTML', '');
 
214
 
 
215
        if (footer_slot !== null) {
 
216
            this._footer_slot_box.appendChild(footer_slot);
 
217
        }
 
218
    },
 
219
 
 
220
    /**
 
221
     * Return the batch page information.
 
222
     *
 
223
     * @method _getBatches
 
224
     * @protected
 
225
     */
 
226
    _getBatches: function() {
 
227
        var batches = this.get(BATCHES);
 
228
 
 
229
        if (batches === null) {
 
230
            var batch_count = this.get(BATCH_COUNT);
 
231
            if (batch_count === null) {
 
232
                batches = [];
 
233
            }
 
234
            else {
 
235
                batches = [];
 
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 });
 
240
                    }
 
241
                }
 
242
            }
 
243
        }
 
244
        return batches;
 
245
    },
 
246
 
 
247
    /**
 
248
     * Update the batches container in the UI.
 
249
     *
 
250
     * @method _syncBatchesUI
 
251
     * @protected
 
252
     */
 
253
    _syncBatchesUI: function() {
 
254
        var batches = this._getBatches();
 
255
 
 
256
        // Clear previous batches.
 
257
        Y.Event.purgeElement(this._batches_box, true);
 
258
        this._batches_box.set('innerHTML', '');
 
259
 
 
260
        if (batches.length === 0) {
 
261
            this._prev_button = null;
 
262
            this._next_button = null;
 
263
            return;
 
264
        }
 
265
 
 
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);
 
272
            this.fire(
 
273
                SEARCH, this.get(CURRENT_SEARCH_STRING),
 
274
                batches[selected].value);
 
275
        }, this);
 
276
        this._batches_box.appendChild(this._prev_button);
 
277
 
 
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);
 
283
 
 
284
            batch_item.on('click', function (e) {
 
285
                this.set(SELECTED_BATCH, i);
 
286
                this.fire(
 
287
                    SEARCH, this.get(CURRENT_SEARCH_STRING), data.value);
 
288
            }, this);
 
289
        }, this);
 
290
 
 
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);
 
296
            this.fire(
 
297
                SEARCH, this.get(CURRENT_SEARCH_STRING),
 
298
                batches[selected].value);
 
299
        }, this);
 
300
    },
 
301
 
 
302
    /**
 
303
     * Synchronize the selected batch with the UI.
 
304
     *
 
305
     * @method _syncSelectedBatchUI
 
306
     * @protected
 
307
     */
 
308
    _syncSelectedBatchUI: function() {
 
309
        var idx = this.get(SELECTED_BATCH);
 
310
        var items = this._batches_box.all('span');
 
311
        if (items.size()) {
 
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());
 
316
        }
 
317
    },
 
318
 
 
319
    /**
 
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
 
327
     */
 
328
    _text_or_link: function(text, href, css) {
 
329
        var result;
 
330
        if (href) {
 
331
            result=Y.Node.create('<a></a>').addClass(css);
 
332
            result.set('text', text).set('href', href);
 
333
            Y.on('click', function(e) {
 
334
                e.halt();
 
335
                window.open(href);
 
336
            }, result);
 
337
        } else {
 
338
            result = document.createTextNode(text);
 
339
        }
 
340
        return result;
 
341
    },
 
342
 
 
343
    /**
 
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
 
348
     * specified URLs.
 
349
     * @param data a json data object with the details to render
 
350
     */
 
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('&nbsp;(');
 
361
            li_title.appendChild(alt_title);
 
362
            li_title.appendChild(')');
 
363
        }
 
364
        return li_title;
 
365
    },
 
366
 
 
367
    /**
 
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
 
372
     */
 
373
    _renderBadgesUI: function(data) {
 
374
        if (data.badges) {
 
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>')
 
380
                    .addClass('badge')
 
381
                    .set('src', badge_url)
 
382
                    .set('alt', badge_alt);
 
383
                badges.appendChild(badge);
 
384
            });
 
385
            return badges;
 
386
        }
 
387
        return null;
 
388
    },
 
389
 
 
390
    /**
 
391
     * Render a node containing the description part of the picker entry.
 
392
     * @param data a json data object with the details to render
 
393
     */
 
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),
 
400
                li_desc.one('br'));
 
401
        }
 
402
        return li_desc;
 
403
    },
 
404
 
 
405
    /**
 
406
     * Update the UI based on the results attribute.
 
407
     *
 
408
     * @method _syncResultsUI
 
409
     * @protected
 
410
     */
 
411
    _syncResultsUI: function() {
 
412
        var results = this.get(RESULTS);
 
413
 
 
414
        // Remove any previous results.
 
415
        Y.Event.purgeElement(this._results_box, true);
 
416
        this._results_box.set('innerHTML', '');
 
417
 
 
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);
 
428
            if (data.css) {
 
429
                li.addClass(data.css);
 
430
            }
 
431
            if (data.image) {
 
432
                li.appendChild(
 
433
                    Y.Node.create('<img />').set('src', data.image));
 
434
            }
 
435
            if (li_badges !== null)
 
436
                li.appendChild(li_badges);
 
437
            li.appendChild(li_title);
 
438
            li.appendChild(li_desc);
 
439
            // Attach handlers.
 
440
            li.on('click', function (e, value) {
 
441
                this.fire(SAVE, value);
 
442
            }, this, data);
 
443
 
 
444
            this._results_box.appendChild(li);
 
445
        }, this);
 
446
 
 
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>');
 
451
            msg.appendChild(
 
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);
 
457
        } else {
 
458
            this._results_box.removeClass(C_NO_RESULTS);
 
459
        }
 
460
 
 
461
        if (results.length) {
 
462
            // Set PrettyOverlay's green progress bar to 100%.
 
463
            this.set('progress', 100);
 
464
        } else {
 
465
            // Set PrettyOverlay's green progress bar to 50%.
 
466
            this.set('progress', 50);
 
467
        }
 
468
    },
 
469
 
 
470
    /**
 
471
     * Sync UI with search mode. Disable the search input and button.
 
472
     *
 
473
     * @method _syncSearchModeUI
 
474
     * @protected
 
475
     */
 
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);
 
480
        if (search_mode) {
 
481
            this.get(BOUNDING_BOX).addClass(C_SEARCH_MODE);
 
482
        } else {
 
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();
 
488
        }
 
489
    },
 
490
 
 
491
    /**
 
492
     * Sync UI with the error message.
 
493
     *
 
494
     * @method _syncErrorUI
 
495
     * @protected
 
496
     */
 
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);
 
502
        } else {
 
503
            this._error_box.appendChild(document.createTextNode(error));
 
504
            this.get(BOUNDING_BOX).addClass(C_ERROR_MODE);
 
505
        }
 
506
    },
 
507
 
 
508
    /**
 
509
     * Create the widget's HTML components.
 
510
     * <p>
 
511
     * This method is invoked after renderUI is invoked for the Widget class
 
512
     * using YUI's aop infrastructure.
 
513
     * </p>
 
514
     *
 
515
     * @method _renderUIPicker
 
516
     * @protected
 
517
     */
 
518
    _renderUIPicker: function() {
 
519
        this._search_button = Y.Node.create(Y.lazr.ui.SEARCH_BUTTON);
 
520
 
 
521
        var search_box = Y.Node.create([
 
522
            '<div>',
 
523
            '<input type="text" size="20" name="search" ',
 
524
            'autocomplete="off"/>',
 
525
            '<div></div></div>'].join(""));
 
526
 
 
527
        this._search_input = search_box.one('input');
 
528
        this._search_input.addClass(C_SEARCH);
 
529
 
 
530
        this._error_box = search_box.one('div');
 
531
        this._error_box.addClass(C_ERROR);
 
532
 
 
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);
 
537
 
 
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);
 
541
 
 
542
        this._results_box = Y.Node.create('<ul></ul>');
 
543
        this._results_box.addClass(C_RESULTS);
 
544
 
 
545
        this._batches_box = Y.Node.create('<div></div');
 
546
        this._batches_box.addClass(C_BATCHES);
 
547
 
 
548
        this._footer_slot_box = Y.Node.create('<div></div');
 
549
        this._footer_slot_box.addClass(C_FOOTER_SLOT);
 
550
 
 
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');
 
557
 
 
558
        this.setStdModContent(Y.WidgetStdMod.BODY, body, Y.WidgetStdMod.APPEND);
 
559
    },
 
560
 
 
561
    /**
 
562
     * Bind the widget's DOM elements to their event handlers.
 
563
     * <p>
 
564
     * This method is invoked after bindUI is invoked for the Widget class
 
565
     * using YUI's aop infrastructure.
 
566
     * </p>
 
567
     *
 
568
     * @method _bindUIPicker
 
569
     * @protected
 
570
     */
 
571
    _bindUIPicker: function() {
 
572
        Y.on('click', this._defaultSearchUserAction, this._search_button,
 
573
             this);
 
574
 
 
575
        // Enter key
 
576
        Y.on(
 
577
            'key', this._defaultSearchUserAction, this._search_input,
 
578
            'down:13', this);
 
579
 
 
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
 
587
                // widget.
 
588
                this.set('centered', true);
 
589
                this._search_input.focus();
 
590
            }
 
591
        }, this);
 
592
 
 
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);
 
598
        }, this);
 
599
 
 
600
        // Update the search slot box whenever the "search_slot" property
 
601
        // is changed.
 
602
        this.after('search_slotChange', function (e) {
 
603
            this._syncSearchSlotUI();
 
604
        }, this);
 
605
 
 
606
        // Update the footer slot box whenever the "footer_slot" property
 
607
        // is changed.
 
608
        this.after('footer_slotChange', function (e) {
 
609
            this._syncFooterSlotUI();
 
610
        }, this);
 
611
 
 
612
        // Update the batch list whenever the "batches" or "results" property
 
613
        // is changed.
 
614
        var doBatchesChange = function (e) {
 
615
            this._syncBatchesUI();
 
616
            this._syncSelectedBatchUI();
 
617
        };
 
618
 
 
619
        this.after('batchesChange', doBatchesChange, this);
 
620
        this.after('resultsChange', doBatchesChange, this);
 
621
 
 
622
        // Keep the UI in sync with the currently selected batch.
 
623
        this.after('selected_batchChange', function (e) {
 
624
            this._syncSelectedBatchUI();
 
625
        }, this);
 
626
 
 
627
        // Update the display whenever the "results" property is changed.
 
628
        this.after('search_modeChange', function (e) {
 
629
            this._syncSearchModeUI();
 
630
        }, this);
 
631
 
 
632
        // Update the display whenever the "error" property is changed.
 
633
        this.after('errorChange', function (e) {
 
634
            this._syncErrorUI();
 
635
        });
 
636
    },
 
637
 
 
638
    /**
 
639
     * Synchronize the search box, error message and results with the UI.
 
640
     * <p>
 
641
     * This method is invoked after syncUI is invoked for the Widget class
 
642
     * using YUI's aop infrastructure.
 
643
     * </p>
 
644
     *
 
645
     * @method _syncUIPicker
 
646
     * @protected
 
647
     */
 
648
    _syncUIPicker: function() {
 
649
        this._syncResultsUI();
 
650
        this._syncSearchModeUI();
 
651
        this._syncBatchesUI();
 
652
        this._syncSelectedBatchUI();
 
653
        this._syncErrorUI();
 
654
        this._search_input.focus();
 
655
    },
 
656
 
 
657
    /*
 
658
     * Clear all elements of the picker, resetting it to its original state.
 
659
     *
 
660
     * @method _clear
 
661
     * @param e {Object} The event object.
 
662
     * @protected
 
663
     */
 
664
    _clear: function() {
 
665
        this.set(CURRENT_SEARCH_STRING, '');
 
666
        this.set(ERROR, '');
 
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', '');
 
672
    },
 
673
 
 
674
    /**
 
675
     * Handle clicks on the 'Search' button or entering the enter key in the
 
676
     * search field.  This fires the search event.
 
677
     *
 
678
     * @method _defaultSearchUserAction
 
679
     * @param e {Event.Facade} An Event Facade object.
 
680
     * @private
 
681
     */
 
682
    _defaultSearchUserAction: function(e) {
 
683
        e.preventDefault();
 
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);
 
690
        } else {
 
691
            this.set(CURRENT_SEARCH_STRING, search_string);
 
692
            this.fire(SEARCH, search_string);
 
693
        }
 
694
    },
 
695
 
 
696
    /**
 
697
     * By default, the search event puts the widget in search mode. It also
 
698
     * clears the error, if there is any.
 
699
     *
 
700
     * @method _defaultSearch
 
701
     * @param e {Event.Facade} An Event Facade object.
 
702
     * @protected
 
703
     */
 
704
    _defaultSearch: function(e) {
 
705
        this.set(ERROR, null);
 
706
        this.set(SEARCH_MODE, true);
 
707
    },
 
708
 
 
709
    /**
 
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'.
 
712
     *
 
713
     * @method _defaultCancel
 
714
     * @param e {Event.Facade} An Event Facade object.
 
715
     * @protected
 
716
     */
 
717
    _defaultCancel : function(e) {
 
718
        Picker.superclass._defaultCancel.apply(this, arguments);
 
719
        if ( this.get('clear_on_cancel') ) {
 
720
            this._clear();
 
721
        }
 
722
    },
 
723
 
 
724
    /**
 
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
 
728
     * event.
 
729
     *
 
730
     * @method _defaultSave
 
731
     * @param e {Event.Facade} An Event Facade object.
 
732
     * @protected
 
733
     */
 
734
    _defaultSave : function(e) {
 
735
        this.hide();
 
736
        if ( this.get('clear_on_save') ) {
 
737
            this._clear();
 
738
        }
 
739
    },
 
740
 
 
741
    /**
 
742
     * By default, the select-batch event turns on search-mode.
 
743
     *
 
744
     * @method _defaultSelectBatch
 
745
     * @param e {Event.Facade} An Event Facade object.
 
746
     * @protected
 
747
     */
 
748
    _defaultSelectBatch: function(e) {
 
749
        this.set(SEARCH_MODE, true);
 
750
    }
 
751
    });
 
752
 
 
753
Picker.NAME = PICKER;
 
754
 
 
755
/**
 
756
 * The details index of the save result.
 
757
 *
 
758
 * @static
 
759
 * @property SAVE_RESULT
 
760
 */
 
761
Picker.SAVE_RESULT = 0;
 
762
 
 
763
/**
 
764
 * The details index of the search string.
 
765
 *
 
766
 * @static
 
767
 * @property SEARCH_STRING
 
768
 */
 
769
Picker.SEARCH_STRING = 0;
 
770
 
 
771
/**
 
772
 * The details index of the selected batch value.
 
773
 *
 
774
 * @static
 
775
 * @property SELECTED_BATCH_VALUE
 
776
 */
 
777
Picker.SELECTED_BATCH_VALUE = 1;
 
778
 
 
779
 
 
780
Picker.ATTRS = {
 
781
    /**
 
782
     * Whether or not the search box and result list should be cleared when
 
783
     * the save event is fired.
 
784
     *
 
785
     * @attribute clear_on_save
 
786
     * @type Boolean
 
787
     */
 
788
    clear_on_save: { value: true },
 
789
 
 
790
    /**
 
791
     * Whether or not the search box and result list should be cleared when
 
792
     * the cancel event is fired.
 
793
     *
 
794
     * @attribute clear_on_cancel
 
795
     * @type Boolean
 
796
     */
 
797
    clear_on_cancel: { value: false },
 
798
 
 
799
    /**
 
800
     * A CSS selector for the DOM element that will activate (show) the picker
 
801
     * once clicked.
 
802
     *
 
803
     * @attribute picker_activator
 
804
     * @type String
 
805
     */
 
806
    picker_activator: { value: null },
 
807
 
 
808
    /**
 
809
     * An extra CSS class to be added to the picker_activator, generally used
 
810
     * to distinguish regular links from js-triggering ones.
 
811
     *
 
812
     * @attribute picker_activator_css_class
 
813
     * @type String
 
814
     */
 
815
    picker_activator_css_class: { value: 'js-action' },
 
816
 
 
817
    /**
 
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.
 
821
     *
 
822
     * @attribute min_search_chars
 
823
     * @type Integer
 
824
     */
 
825
    min_search_chars: { value: 3 },
 
826
 
 
827
    /**
 
828
     * The current search string, which is needed when clicking on a different
 
829
     * batch if the search input has been modified.
 
830
     *
 
831
     * @attribute current_search_string
 
832
     * @type String
 
833
     */
 
834
    current_search_string: {value: ''},
 
835
 
 
836
    /**
 
837
     * Results currently displayed by the widget. Updating this value
 
838
     * automatically updates the display.
 
839
     *
 
840
     * @attribute results
 
841
     * @type Array
 
842
     */
 
843
    results: { value: [] },
 
844
 
 
845
    /**
 
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
 
849
     * event never fires.
 
850
     *
 
851
     * @attribute search_slot
 
852
     * @type Node
 
853
     */
 
854
    search_slot: {value: null},
 
855
 
 
856
    /**
 
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
 
862
     * event never fires.
 
863
     *
 
864
     * @attribute footer_slot
 
865
     * @type Node
 
866
     */
 
867
    footer_slot: {value: null},
 
868
 
 
869
    /**
 
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.
 
873
     *
 
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
 
876
     * event).
 
877
     *
 
878
     * @attribute batches
 
879
     * @type Array
 
880
     */
 
881
    batches: {value: null},
 
882
 
 
883
    /**
 
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.
 
890
     *
 
891
     * If 'batches' is set (see above), batch_count is ignored.
 
892
     *
 
893
     * @attribute batch_count
 
894
     * @type Integer
 
895
     */
 
896
    batch_count: {value: null},
 
897
 
 
898
    /**
 
899
     * Batch currently selected.
 
900
     *
 
901
     * @attribute selected_batch
 
902
     * @type Integer
 
903
     */
 
904
    selected_batch: {
 
905
        value: 0,
 
906
        getter: function (value) {
 
907
            return value || 0;
 
908
        },
 
909
        validator: function (value) {
 
910
            var batches = this._getBatches();
 
911
            return Y.Lang.isNumber(value) &&
 
912
                   value >= 0 &&
 
913
                   value < batches.length;
 
914
        }},
 
915
 
 
916
    /**
 
917
     * Flag indicating if the widget is currently in search mode (so users
 
918
     * has triggered a search and we are waiting for results.)
 
919
     *
 
920
     * @attribute search_mode
 
921
     * @type Boolean
 
922
     */
 
923
    search_mode: { value: false },
 
924
 
 
925
    /**
 
926
     * The current error message. This puts the widget in 'error-mode',
 
927
     * setting this value to null clears that state.
 
928
     *
 
929
     * @attribute error
 
930
     * @type String
 
931
     */
 
932
    error: { value: null },
 
933
 
 
934
    /**
 
935
     * The message to display when the search returned no results. This string
 
936
     * can contain a 'query' placeholder
 
937
     *
 
938
     * @attribute no_results_search_message
 
939
     * @type String
 
940
     * @default No items matched "{query}".
 
941
     */
 
942
    no_results_search_message: {
 
943
        value: 'No items matched "{query}".'
 
944
    }
 
945
};
 
946
 
 
947
 
 
948
/**
 
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.
 
953
 *
 
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.
 
957
 *
 
958
 * @class TextFieldPickerPlugin
 
959
 * @extends Y.Plugin.Base
 
960
 * @constructor
 
961
 */
 
962
 
 
963
function TextFieldPickerPlugin(config) {
 
964
    TextFieldPickerPlugin.superclass.constructor.apply(this, arguments);
 
965
}
 
966
 
 
967
TextFieldPickerPlugin.NAME = 'TextFieldPickerPlugin';
 
968
TextFieldPickerPlugin.NS = 'txtpicker';
 
969
 
 
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.
 
978
            input.blur();
 
979
            input.focus();
 
980
        });
 
981
        this.doAfter('show', function() {
 
982
            if ( input.get("value") ) {
 
983
                this.get('host')._search_input.set('value', input.get("value"));
 
984
            }
 
985
        });
 
986
    }
 
987
});
 
988
 
 
989
Y.lazr.Picker = Picker;
 
990
Y.lazr.TextFieldPickerPlugin = TextFieldPickerPlugin;
 
991
 
 
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"]
 
996
});