Merge branch 'MDL-32509' of git://github.com/danpoltawski/moodle
[moodle.git] / lib / yui / 3.5.0 / build / autocomplete-list / autocomplete-list.js
blobfe97fe6edbefc3a7198ffad4b324f7886d97bbe6
1 /*
2 YUI 3.5.0 (build 5089)
3 Copyright 2012 Yahoo! Inc. All rights reserved.
4 Licensed under the BSD License.
5 http://yuilibrary.com/license/
6 */
7 YUI.add('autocomplete-list', function(Y) {
9 /**
10 Traditional autocomplete dropdown list widget, just like Mom used to make.
12 @module autocomplete
13 @submodule autocomplete-list
14 **/
16 /**
17 Traditional autocomplete dropdown list widget, just like Mom used to make.
19 @class AutoCompleteList
20 @extends Widget
21 @uses AutoCompleteBase
22 @uses WidgetPosition
23 @uses WidgetPositionAlign
24 @constructor
25 @param {Object} config Configuration object.
26 **/
28 var Lang   = Y.Lang,
29     Node   = Y.Node,
30     YArray = Y.Array,
32     // Whether or not we need an iframe shim.
33     useShim = Y.UA.ie && Y.UA.ie < 7,
35     // keyCode constants.
36     KEY_TAB = 9,
38     // String shorthand.
39     _CLASS_ITEM        = '_CLASS_ITEM',
40     _CLASS_ITEM_ACTIVE = '_CLASS_ITEM_ACTIVE',
41     _CLASS_ITEM_HOVER  = '_CLASS_ITEM_HOVER',
42     _SELECTOR_ITEM     = '_SELECTOR_ITEM',
44     ACTIVE_ITEM      = 'activeItem',
45     ALWAYS_SHOW_LIST = 'alwaysShowList',
46     CIRCULAR         = 'circular',
47     HOVERED_ITEM     = 'hoveredItem',
48     ID               = 'id',
49     ITEM             = 'item',
50     LIST             = 'list',
51     RESULT           = 'result',
52     RESULTS          = 'results',
53     VISIBLE          = 'visible',
54     WIDTH            = 'width',
56     // Event names.
57     EVT_SELECT = 'select',
59 List = Y.Base.create('autocompleteList', Y.Widget, [
60     Y.AutoCompleteBase,
61     Y.WidgetPosition,
62     Y.WidgetPositionAlign
63 ], {
64     // -- Prototype Properties -------------------------------------------------
65     ARIA_TEMPLATE: '<div/>',
66     ITEM_TEMPLATE: '<li/>',
67     LIST_TEMPLATE: '<ul/>',
69     // Widget automatically attaches delegated event handlers to everything in
70     // Y.Node.DOM_EVENTS, including synthetic events. Since Widget's event
71     // delegation won't work for the synthetic valuechange event, and since
72     // it creates a name collision between the backcompat "valueChange" synth
73     // event alias and AutoCompleteList's "valueChange" event for the "value"
74     // attr, this hack is necessary in order to prevent Widget from attaching
75     // valuechange handlers.
76     UI_EVENTS: (function () {
77         var uiEvents = Y.merge(Y.Node.DOM_EVENTS);
79         delete uiEvents.valuechange;
80         delete uiEvents.valueChange;
82         return uiEvents;
83     }()),
85     // -- Lifecycle Prototype Methods ------------------------------------------
86     initializer: function () {
87         var inputNode = this.get('inputNode');
89         if (!inputNode) {
90             Y.error('No inputNode specified.');
91             return;
92         }
94         this._inputNode  = inputNode;
95         this._listEvents = [];
97         // This ensures that the list is rendered inside the same parent as the
98         // input node by default, which is necessary for proper ARIA support.
99         this.DEF_PARENT_NODE = inputNode.get('parentNode');
101         // Cache commonly used classnames and selectors for performance.
102         this[_CLASS_ITEM]        = this.getClassName(ITEM);
103         this[_CLASS_ITEM_ACTIVE] = this.getClassName(ITEM, 'active');
104         this[_CLASS_ITEM_HOVER]  = this.getClassName(ITEM, 'hover');
105         this[_SELECTOR_ITEM]     = '.' + this[_CLASS_ITEM];
107         /**
108         Fires when an autocomplete suggestion is selected from the list,
109         typically via a keyboard action or mouse click.
111         @event select
112         @param {Node} itemNode List item node that was selected.
113         @param {Object} result AutoComplete result object.
114         @preventable _defSelectFn
115         **/
116         this.publish(EVT_SELECT, {
117             defaultFn: this._defSelectFn
118         });
119     },
121     destructor: function () {
122         while (this._listEvents.length) {
123             this._listEvents.pop().detach();
124         }
126         if (this._ariaNode) {
127             this._ariaNode.remove().destroy(true);
128         }
129     },
131     bindUI: function () {
132         this._bindInput();
133         this._bindList();
134     },
136     renderUI: function () {
137         var ariaNode    = this._createAriaNode(),
138             boundingBox = this.get('boundingBox'),
139             contentBox  = this.get('contentBox'),
140             inputNode   = this._inputNode,
141             listNode    = this._createListNode(),
142             parentNode  = inputNode.get('parentNode');
144         inputNode.addClass(this.getClassName('input')).setAttrs({
145             'aria-autocomplete': LIST,
146             'aria-expanded'    : false,
147             'aria-owns'        : listNode.get('id')
148         });
150         // ARIA node must be outside the widget or announcements won't be made
151         // when the widget is hidden.
152         parentNode.append(ariaNode);
154         // Add an iframe shim for IE6.
155         if (useShim) {
156             boundingBox.plug(Y.Plugin.Shim);
157         }
159         // Force position: absolute on the boundingBox. This works around a
160         // potential CSS loading race condition in Gecko that can cause the
161         // boundingBox to become relatively positioned, which is all kinds of
162         // no good.
163         boundingBox.setStyle('position', 'absolute');
165         this._ariaNode    = ariaNode;
166         this._boundingBox = boundingBox;
167         this._contentBox  = contentBox;
168         this._listNode    = listNode;
169         this._parentNode  = parentNode;
170     },
172     syncUI: function () {
173         // No need to call _syncPosition() here; the other _sync methods will
174         // call it when necessary.
175         this._syncResults();
176         this._syncVisibility();
177     },
179     // -- Public Prototype Methods ---------------------------------------------
181     /**
182     Hides the list, unless the `alwaysShowList` attribute is `true`.
184     @method hide
185     @see show
186     @chainable
187     **/
188     hide: function () {
189         return this.get(ALWAYS_SHOW_LIST) ? this : this.set(VISIBLE, false);
190     },
192     /**
193     Selects the specified _itemNode_, or the current `activeItem` if _itemNode_
194     is not specified.
196     @method selectItem
197     @param {Node} [itemNode] Item node to select.
198     @param {EventFacade} [originEvent] Event that triggered the selection, if
199         any.
200     @chainable
201     **/
202     selectItem: function (itemNode, originEvent) {
203         if (itemNode) {
204             if (!itemNode.hasClass(this[_CLASS_ITEM])) {
205                 return this;
206             }
207         } else {
208             itemNode = this.get(ACTIVE_ITEM);
210             if (!itemNode) {
211                 return this;
212             }
213         }
215         this.fire(EVT_SELECT, {
216             itemNode   : itemNode,
217             originEvent: originEvent || null,
218             result     : itemNode.getData(RESULT)
219         });
221         return this;
222     },
224     // -- Protected Prototype Methods ------------------------------------------
226     /**
227     Activates the next item after the currently active item. If there is no next
228     item and the `circular` attribute is `true`, focus will wrap back to the
229     input node.
231     @method _activateNextItem
232     @chainable
233     @protected
234     **/
235     _activateNextItem: function () {
236         var item = this.get(ACTIVE_ITEM),
237             nextItem;
239         if (item) {
240             nextItem = item.next(this[_SELECTOR_ITEM]) ||
241                     (this.get(CIRCULAR) ? null : item);
242         } else {
243             nextItem = this._getFirstItemNode();
244         }
246         this.set(ACTIVE_ITEM, nextItem);
248         return this;
249     },
251     /**
252     Activates the item previous to the currently active item. If there is no
253     previous item and the `circular` attribute is `true`, focus will wrap back
254     to the input node.
256     @method _activatePrevItem
257     @chainable
258     @protected
259     **/
260     _activatePrevItem: function () {
261         var item     = this.get(ACTIVE_ITEM),
262             prevItem = item ? item.previous(this[_SELECTOR_ITEM]) :
263                     this.get(CIRCULAR) && this._getLastItemNode();
265         this.set(ACTIVE_ITEM, prevItem || null);
267         return this;
268     },
270     /**
271     Appends the specified result _items_ to the list inside a new item node.
273     @method _add
274     @param {Array|Node|HTMLElement|String} items Result item or array of
275         result items.
276     @return {NodeList} Added nodes.
277     @protected
278     **/
279     _add: function (items) {
280         var itemNodes = [];
282         YArray.each(Lang.isArray(items) ? items : [items], function (item) {
283             itemNodes.push(this._createItemNode(item).setData(RESULT, item));
284         }, this);
286         itemNodes = Y.all(itemNodes);
287         this._listNode.append(itemNodes.toFrag());
289         return itemNodes;
290     },
292     /**
293     Updates the ARIA live region with the specified message.
295     @method _ariaSay
296     @param {String} stringId String id (from the `strings` attribute) of the
297         message to speak.
298     @param {Object} [subs] Substitutions for placeholders in the string.
299     @protected
300     **/
301     _ariaSay: function (stringId, subs) {
302         var message = this.get('strings.' + stringId);
303         this._ariaNode.setContent(subs ? Lang.sub(message, subs) : message);
304     },
306     /**
307     Binds `inputNode` events and behavior.
309     @method _bindInput
310     @protected
311     **/
312     _bindInput: function () {
313         var inputNode = this._inputNode,
314             alignNode, alignWidth, tokenInput;
316         // Null align means we can auto-align. Set align to false to prevent
317         // auto-alignment, or a valid alignment config to customize the
318         // alignment.
319         if (this.get('align') === null) {
320             // If this is a tokenInput, align with its bounding box.
321             // Otherwise, align with the inputNode. Bit of a cheat.
322             tokenInput = this.get('tokenInput');
323             alignNode  = (tokenInput && tokenInput.get('boundingBox')) || inputNode;
325             this.set('align', {
326                 node  : alignNode,
327                 points: ['tl', 'bl']
328             });
330             // If no width config is set, attempt to set the list's width to the
331             // width of the alignment node. If the alignment node's width is
332             // falsy, do nothing.
333             if (!this.get(WIDTH) && (alignWidth = alignNode.get('offsetWidth'))) {
334                 this.set(WIDTH, alignWidth);
335             }
336         }
338         // Attach inputNode events.
339         this._listEvents.concat([
340             inputNode.after('blur',  this._afterListInputBlur, this),
341             inputNode.after('focus', this._afterListInputFocus, this)
342         ]);
343     },
345     /**
346     Binds list events.
348     @method _bindList
349     @protected
350     **/
351     _bindList: function () {
352         this._listEvents.concat([
353             Y.one('doc').after('click', this._afterDocClick, this),
354             Y.one('win').after('windowresize', this._syncPosition, this),
356             this.after({
357                 mouseover: this._afterMouseOver,
358                 mouseout : this._afterMouseOut,
360                 activeItemChange    : this._afterActiveItemChange,
361                 alwaysShowListChange: this._afterAlwaysShowListChange,
362                 hoveredItemChange   : this._afterHoveredItemChange,
363                 resultsChange       : this._afterResultsChange,
364                 visibleChange       : this._afterVisibleChange
365             }),
367             this._listNode.delegate('click', this._onItemClick,
368                     this[_SELECTOR_ITEM], this)
369         ]);
370     },
372     /**
373     Clears the contents of the tray.
375     @method _clear
376     @protected
377     **/
378     _clear: function () {
379         this.set(ACTIVE_ITEM, null);
380         this._set(HOVERED_ITEM, null);
382         this._listNode.get('children').remove(true);
383     },
385     /**
386     Creates and returns an ARIA live region node.
388     @method _createAriaNode
389     @return {Node} ARIA node.
390     @protected
391     **/
392     _createAriaNode: function () {
393         var ariaNode = Node.create(this.ARIA_TEMPLATE);
395         return ariaNode.addClass(this.getClassName('aria')).setAttrs({
396             'aria-live': 'polite',
397             role       : 'status'
398         });
399     },
401     /**
402     Creates and returns an item node with the specified _content_.
404     @method _createItemNode
405     @param {Object} result Result object.
406     @return {Node} Item node.
407     @protected
408     **/
409     _createItemNode: function (result) {
410         var itemNode = Node.create(this.ITEM_TEMPLATE);
412         return itemNode.addClass(this[_CLASS_ITEM]).setAttrs({
413             id  : Y.stamp(itemNode),
414             role: 'option'
415         }).setAttribute('data-text', result.text).append(result.display);
416     },
418     /**
419     Creates and returns a list node. If the `listNode` attribute is already set
420     to an existing node, that node will be used.
422     @method _createListNode
423     @return {Node} List node.
424     @protected
425     **/
426     _createListNode: function () {
427         var listNode = this.get('listNode') || Node.create(this.LIST_TEMPLATE);
429         listNode.addClass(this.getClassName(LIST)).setAttrs({
430             id  : Y.stamp(listNode),
431             role: 'listbox'
432         });
434         this._set('listNode', listNode);
435         this.get('contentBox').append(listNode);
437         return listNode;
438     },
440     /**
441     Gets the first item node in the list, or `null` if the list is empty.
443     @method _getFirstItemNode
444     @return {Node|null}
445     @protected
446     **/
447     _getFirstItemNode: function () {
448         return this._listNode.one(this[_SELECTOR_ITEM]);
449     },
451     /**
452     Gets the last item node in the list, or `null` if the list is empty.
454     @method _getLastItemNode
455     @return {Node|null}
456     @protected
457     **/
458     _getLastItemNode: function () {
459         return this._listNode.one(this[_SELECTOR_ITEM] + ':last-child');
460     },
462     /**
463     Synchronizes the result list's position and alignment.
465     @method _syncPosition
466     @protected
467     **/
468     _syncPosition: function () {
469         // Force WidgetPositionAlign to refresh its alignment.
470         this._syncUIPosAlign();
472         // Resize the IE6 iframe shim to match the list's dimensions.
473         this._syncShim();
474     },
476     /**
477     Synchronizes the results displayed in the list with those in the _results_
478     argument, or with the `results` attribute if an argument is not provided.
480     @method _syncResults
481     @param {Array} [results] Results.
482     @protected
483     **/
484     _syncResults: function (results) {
485         if (!results) {
486             results = this.get(RESULTS);
487         }
489         this._clear();
491         if (results.length) {
492             this._add(results);
493             this._ariaSay('items_available');
494         }
496         this._syncPosition();
498         if (this.get('activateFirstItem') && !this.get(ACTIVE_ITEM)) {
499             this.set(ACTIVE_ITEM, this._getFirstItemNode());
500         }
501     },
503     /**
504     Synchronizes the size of the iframe shim used for IE6 and lower. In other
505     browsers, this method is a noop.
507     @method _syncShim
508     @protected
509     **/
510     _syncShim: useShim ? function () {
511         this._boundingBox.shim.sync();
512     } : function () {},
514     /**
515     Synchronizes the visibility of the tray with the _visible_ argument, or with
516     the `visible` attribute if an argument is not provided.
518     @method _syncVisibility
519     @param {Boolean} [visible] Visibility.
520     @protected
521     **/
522     _syncVisibility: function (visible) {
523         if (this.get(ALWAYS_SHOW_LIST)) {
524             visible = true;
525             this.set(VISIBLE, visible);
526         }
528         if (typeof visible === 'undefined') {
529             visible = this.get(VISIBLE);
530         }
532         this._inputNode.set('aria-expanded', visible);
533         this._boundingBox.set('aria-hidden', !visible);
535         if (visible) {
536             this._syncPosition();
537         } else {
538             this.set(ACTIVE_ITEM, null);
539             this._set(HOVERED_ITEM, null);
541             // Force a reflow to work around a glitch in IE6 and 7 where some of
542             // the contents of the list will sometimes remain visible after the
543             // container is hidden.
544             this._boundingBox.get('offsetWidth');
545         }
547         // In some pages, IE7 fails to repaint the contents of the list after it
548         // becomes visible. Toggling a bogus class on the body forces a repaint
549         // that fixes the issue.
550         if (Y.UA.ie === 7) {
551             Y.one('body')
552                 .addClass('yui3-ie7-sucks')
553                 .removeClass('yui3-ie7-sucks');
554         }
555     },
557     // -- Protected Event Handlers ---------------------------------------------
559     /**
560     Handles `activeItemChange` events.
562     @method _afterActiveItemChange
563     @param {EventFacade} e
564     @protected
565     **/
566     _afterActiveItemChange: function (e) {
567         var inputNode = this._inputNode,
568             newVal    = e.newVal,
569             prevVal   = e.prevVal,
570             node;
572         // The previous item may have disappeared by the time this handler runs,
573         // so we need to be careful.
574         if (prevVal && prevVal._node) {
575             prevVal.removeClass(this[_CLASS_ITEM_ACTIVE]);
576         }
578         if (newVal) {
579             newVal.addClass(this[_CLASS_ITEM_ACTIVE]);
580             inputNode.set('aria-activedescendant', newVal.get(ID));
581         } else {
582             inputNode.removeAttribute('aria-activedescendant');
583         }
585         if (this.get('scrollIntoView')) {
586             node = newVal || inputNode;
588             if (!node.inRegion(Y.DOM.viewportRegion(), true)
589                     || !node.inRegion(this._contentBox, true)) {
591                 node.scrollIntoView();
592             }
593         }
594     },
596     /**
597     Handles `alwaysShowListChange` events.
599     @method _afterAlwaysShowListChange
600     @param {EventFacade} e
601     @protected
602     **/
603     _afterAlwaysShowListChange: function (e) {
604         this.set(VISIBLE, e.newVal || this.get(RESULTS).length > 0);
605     },
607     /**
608     Handles click events on the document. If the click is outside both the
609     input node and the bounding box, the list will be hidden.
611     @method _afterDocClick
612     @param {EventFacade} e
613     @protected
614     @since 3.5.0
615     **/
616     _afterDocClick: function (e) {
617         var boundingBox = this._boundingBox,
618             target      = e.target;
620         if (target !== this._inputNode && target !== boundingBox &&
621                 !boundingBox.one(target.get('id'))) {
623             this.hide();
624         }
625     },
627     /**
628     Handles `hoveredItemChange` events.
630     @method _afterHoveredItemChange
631     @param {EventFacade} e
632     @protected
633     **/
634     _afterHoveredItemChange: function (e) {
635         var newVal  = e.newVal,
636             prevVal = e.prevVal;
638         if (prevVal) {
639             prevVal.removeClass(this[_CLASS_ITEM_HOVER]);
640         }
642         if (newVal) {
643             newVal.addClass(this[_CLASS_ITEM_HOVER]);
644         }
645     },
647     /**
648     Handles `inputNode` blur events.
650     @method _afterListInputBlur
651     @protected
652     **/
653     _afterListInputBlur: function () {
654         this._listInputFocused = false;
656         if (this.get(VISIBLE) &&
657                 !this._mouseOverList &&
658                 (this._lastInputKey !== KEY_TAB ||
659                     !this.get('tabSelect') ||
660                     !this.get(ACTIVE_ITEM))) {
661             this.hide();
662         }
663     },
665     /**
666     Handles `inputNode` focus events.
668     @method _afterListInputFocus
669     @protected
670     **/
671     _afterListInputFocus: function () {
672         this._listInputFocused = true;
673     },
675     /**
676     Handles `mouseover` events.
678     @method _afterMouseOver
679     @param {EventFacade} e
680     @protected
681     **/
682     _afterMouseOver: function (e) {
683         var itemNode = e.domEvent.target.ancestor(this[_SELECTOR_ITEM], true);
685         this._mouseOverList = true;
687         if (itemNode) {
688             this._set(HOVERED_ITEM, itemNode);
689         }
690     },
692     /**
693     Handles `mouseout` events.
695     @method _afterMouseOut
696     @param {EventFacade} e
697     @protected
698     **/
699     _afterMouseOut: function () {
700         this._mouseOverList = false;
701         this._set(HOVERED_ITEM, null);
702     },
704     /**
705     Handles `resultsChange` events.
707     @method _afterResultsChange
708     @param {EventFacade} e
709     @protected
710     **/
711     _afterResultsChange: function (e) {
712         this._syncResults(e.newVal);
714         if (!this.get(ALWAYS_SHOW_LIST)) {
715             this.set(VISIBLE, !!e.newVal.length);
716         }
717     },
719     /**
720     Handles `visibleChange` events.
722     @method _afterVisibleChange
723     @param {EventFacade} e
724     @protected
725     **/
726     _afterVisibleChange: function (e) {
727         this._syncVisibility(!!e.newVal);
728     },
730     /**
731     Delegated event handler for item `click` events.
733     @method _onItemClick
734     @param {EventFacade} e
735     @protected
736     **/
737     _onItemClick: function (e) {
738         var itemNode = e.currentTarget;
740         this.set(ACTIVE_ITEM, itemNode);
741         this.selectItem(itemNode, e);
742     },
744     // -- Protected Default Event Handlers -------------------------------------
746     /**
747     Default `select` event handler.
749     @method _defSelectFn
750     @param {EventFacade} e
751     @protected
752     **/
753     _defSelectFn: function (e) {
754         var text = e.result.text;
756         // TODO: support typeahead completion, etc.
757         this._inputNode.focus();
758         this._updateValue(text);
759         this._ariaSay('item_selected', {item: text});
760         this.hide();
761     }
762 }, {
763     ATTRS: {
764         /**
765         If `true`, the first item in the list will be activated by default when
766         the list is initially displayed and when results change.
768         @attribute activateFirstItem
769         @type Boolean
770         @default false
771         **/
772         activateFirstItem: {
773             value: false
774         },
776         /**
777         Item that's currently active, if any. When the user presses enter, this
778         is the item that will be selected.
780         @attribute activeItem
781         @type Node
782         **/
783         activeItem: {
784             setter: Y.one,
785             value: null
786         },
788         /**
789         If `true`, the list will remain visible even when there are no results
790         to display.
792         @attribute alwaysShowList
793         @type Boolean
794         @default false
795         **/
796         alwaysShowList: {
797             value: false
798         },
800         /**
801         If `true`, keyboard navigation will wrap around to the opposite end of
802         the list when navigating past the first or last item.
804         @attribute circular
805         @type Boolean
806         @default true
807         **/
808         circular: {
809             value: true
810         },
812         /**
813         Item currently being hovered over by the mouse, if any.
815         @attribute hoveredItem
816         @type Node|null
817         @readOnly
818         **/
819         hoveredItem: {
820             readOnly: true,
821             value: null
822         },
824         /**
825         Node that will contain result items.
827         @attribute listNode
828         @type Node|null
829         @initOnly
830         **/
831         listNode: {
832             writeOnce: 'initOnly',
833             value: null
834         },
836         /**
837         If `true`, the viewport will be scrolled to ensure that the active list
838         item is visible when necessary.
840         @attribute scrollIntoView
841         @type Boolean
842         @default false
843         **/
844         scrollIntoView: {
845             value: false
846         },
848         /**
849         Translatable strings used by the AutoCompleteList widget.
851         @attribute strings
852         @type Object
853         **/
854         strings: {
855             valueFn: function () {
856                 return Y.Intl.get('autocomplete-list');
857             }
858         },
860         /**
861         If `true`, pressing the tab key while the list is visible will select
862         the active item, if any.
864         @attribute tabSelect
865         @type Boolean
866         @default true
867         **/
868         tabSelect: {
869             value: true
870         },
872         // The "visible" attribute is documented in Widget.
873         visible: {
874             value: false
875         }
876     },
878     CSS_PREFIX: Y.ClassNameManager.getClassName('aclist')
881 Y.AutoCompleteList = List;
884 Alias for <a href="AutoCompleteList.html">`AutoCompleteList`</a>. See that class
885 for API docs.
887 @class AutoComplete
890 Y.AutoComplete = List;
893 }, '3.5.0' ,{lang:['en'], skinnable:true, after:['autocomplete-sources'], requires:['autocomplete-base', 'event-resize', 'node-screen', 'selector-css3', 'shim-plugin', 'widget', 'widget-position', 'widget-position-align']});