3 Copyright 2012 Yahoo! Inc. All rights reserved.
4 Licensed under the BSD License.
5 http://yuilibrary.com/license/
7 YUI.add('autocomplete-list', function(Y) {
10 Traditional autocomplete dropdown list widget, just like Mom used to make.
13 @submodule autocomplete-list
17 Traditional autocomplete dropdown list widget, just like Mom used to make.
19 @class AutoCompleteList
21 @uses AutoCompleteBase
23 @uses WidgetPositionAlign
25 @param {Object} config Configuration object.
32 // Whether or not we need an iframe shim.
33 useShim = Y.UA.ie && Y.UA.ie < 7,
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',
57 EVT_SELECT = 'select',
59 List = Y.Base.create('autocompleteList', Y.Widget, [
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;
85 // -- Lifecycle Prototype Methods ------------------------------------------
86 initializer: function () {
87 var inputNode = this.get('inputNode');
90 Y.error('No inputNode specified.');
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];
108 Fires when an autocomplete suggestion is selected from the list,
109 typically via a keyboard action or mouse click.
112 @param {Node} itemNode List item node that was selected.
113 @param {Object} result AutoComplete result object.
114 @preventable _defSelectFn
116 this.publish(EVT_SELECT, {
117 defaultFn: this._defSelectFn
121 destructor: function () {
122 while (this._listEvents.length) {
123 this._listEvents.pop().detach();
126 if (this._ariaNode) {
127 this._ariaNode.remove().destroy(true);
131 bindUI: function () {
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')
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.
156 boundingBox.plug(Y.Plugin.Shim);
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
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;
172 syncUI: function () {
173 // No need to call _syncPosition() here; the other _sync methods will
174 // call it when necessary.
176 this._syncVisibility();
179 // -- Public Prototype Methods ---------------------------------------------
182 Hides the list, unless the `alwaysShowList` attribute is `true`.
189 return this.get(ALWAYS_SHOW_LIST) ? this : this.set(VISIBLE, false);
193 Selects the specified _itemNode_, or the current `activeItem` if _itemNode_
197 @param {Node} [itemNode] Item node to select.
198 @param {EventFacade} [originEvent] Event that triggered the selection, if
202 selectItem: function (itemNode, originEvent) {
204 if (!itemNode.hasClass(this[_CLASS_ITEM])) {
208 itemNode = this.get(ACTIVE_ITEM);
215 this.fire(EVT_SELECT, {
217 originEvent: originEvent || null,
218 result : itemNode.getData(RESULT)
224 // -- Protected Prototype Methods ------------------------------------------
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
231 @method _activateNextItem
235 _activateNextItem: function () {
236 var item = this.get(ACTIVE_ITEM),
240 nextItem = item.next(this[_SELECTOR_ITEM]) ||
241 (this.get(CIRCULAR) ? null : item);
243 nextItem = this._getFirstItemNode();
246 this.set(ACTIVE_ITEM, nextItem);
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
256 @method _activatePrevItem
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);
271 Appends the specified result _items_ to the list inside a new item node.
274 @param {Array|Node|HTMLElement|String} items Result item or array of
276 @return {NodeList} Added nodes.
279 _add: function (items) {
282 YArray.each(Lang.isArray(items) ? items : [items], function (item) {
283 itemNodes.push(this._createItemNode(item).setData(RESULT, item));
286 itemNodes = Y.all(itemNodes);
287 this._listNode.append(itemNodes.toFrag());
293 Updates the ARIA live region with the specified message.
296 @param {String} stringId String id (from the `strings` attribute) of the
298 @param {Object} [subs] Substitutions for placeholders in the string.
301 _ariaSay: function (stringId, subs) {
302 var message = this.get('strings.' + stringId);
303 this._ariaNode.setContent(subs ? Lang.sub(message, subs) : message);
307 Binds `inputNode` events and behavior.
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
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;
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);
338 // Attach inputNode events.
339 this._listEvents.concat([
340 inputNode.after('blur', this._afterListInputBlur, this),
341 inputNode.after('focus', this._afterListInputFocus, this)
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),
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
367 this._listNode.delegate('click', this._onItemClick,
368 this[_SELECTOR_ITEM], this)
373 Clears the contents of the tray.
378 _clear: function () {
379 this.set(ACTIVE_ITEM, null);
380 this._set(HOVERED_ITEM, null);
382 this._listNode.get('children').remove(true);
386 Creates and returns an ARIA live region node.
388 @method _createAriaNode
389 @return {Node} ARIA node.
392 _createAriaNode: function () {
393 var ariaNode = Node.create(this.ARIA_TEMPLATE);
395 return ariaNode.addClass(this.getClassName('aria')).setAttrs({
396 'aria-live': 'polite',
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.
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),
415 }).setAttribute('data-text', result.text).append(result.display);
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.
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),
434 this._set('listNode', listNode);
435 this.get('contentBox').append(listNode);
441 Gets the first item node in the list, or `null` if the list is empty.
443 @method _getFirstItemNode
447 _getFirstItemNode: function () {
448 return this._listNode.one(this[_SELECTOR_ITEM]);
452 Gets the last item node in the list, or `null` if the list is empty.
454 @method _getLastItemNode
458 _getLastItemNode: function () {
459 return this._listNode.one(this[_SELECTOR_ITEM] + ':last-child');
463 Synchronizes the result list's position and alignment.
465 @method _syncPosition
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.
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.
481 @param {Array} [results] Results.
484 _syncResults: function (results) {
486 results = this.get(RESULTS);
491 if (results.length) {
493 this._ariaSay('items_available');
496 this._syncPosition();
498 if (this.get('activateFirstItem') && !this.get(ACTIVE_ITEM)) {
499 this.set(ACTIVE_ITEM, this._getFirstItemNode());
504 Synchronizes the size of the iframe shim used for IE6 and lower. In other
505 browsers, this method is a noop.
510 _syncShim: useShim ? function () {
511 this._boundingBox.shim.sync();
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.
522 _syncVisibility: function (visible) {
523 if (this.get(ALWAYS_SHOW_LIST)) {
525 this.set(VISIBLE, visible);
528 if (typeof visible === 'undefined') {
529 visible = this.get(VISIBLE);
532 this._inputNode.set('aria-expanded', visible);
533 this._boundingBox.set('aria-hidden', !visible);
536 this._syncPosition();
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');
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.
552 .addClass('yui3-ie7-sucks')
553 .removeClass('yui3-ie7-sucks');
557 // -- Protected Event Handlers ---------------------------------------------
560 Handles `activeItemChange` events.
562 @method _afterActiveItemChange
563 @param {EventFacade} e
566 _afterActiveItemChange: function (e) {
567 var inputNode = this._inputNode,
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]);
579 newVal.addClass(this[_CLASS_ITEM_ACTIVE]);
580 inputNode.set('aria-activedescendant', newVal.get(ID));
582 inputNode.removeAttribute('aria-activedescendant');
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();
597 Handles `alwaysShowListChange` events.
599 @method _afterAlwaysShowListChange
600 @param {EventFacade} e
603 _afterAlwaysShowListChange: function (e) {
604 this.set(VISIBLE, e.newVal || this.get(RESULTS).length > 0);
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
616 _afterDocClick: function (e) {
617 var boundingBox = this._boundingBox,
620 if (target !== this._inputNode && target !== boundingBox &&
621 !boundingBox.one(target.get('id'))) {
628 Handles `hoveredItemChange` events.
630 @method _afterHoveredItemChange
631 @param {EventFacade} e
634 _afterHoveredItemChange: function (e) {
635 var newVal = e.newVal,
639 prevVal.removeClass(this[_CLASS_ITEM_HOVER]);
643 newVal.addClass(this[_CLASS_ITEM_HOVER]);
648 Handles `inputNode` blur events.
650 @method _afterListInputBlur
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))) {
666 Handles `inputNode` focus events.
668 @method _afterListInputFocus
671 _afterListInputFocus: function () {
672 this._listInputFocused = true;
676 Handles `mouseover` events.
678 @method _afterMouseOver
679 @param {EventFacade} e
682 _afterMouseOver: function (e) {
683 var itemNode = e.domEvent.target.ancestor(this[_SELECTOR_ITEM], true);
685 this._mouseOverList = true;
688 this._set(HOVERED_ITEM, itemNode);
693 Handles `mouseout` events.
695 @method _afterMouseOut
696 @param {EventFacade} e
699 _afterMouseOut: function () {
700 this._mouseOverList = false;
701 this._set(HOVERED_ITEM, null);
705 Handles `resultsChange` events.
707 @method _afterResultsChange
708 @param {EventFacade} e
711 _afterResultsChange: function (e) {
712 this._syncResults(e.newVal);
714 if (!this.get(ALWAYS_SHOW_LIST)) {
715 this.set(VISIBLE, !!e.newVal.length);
720 Handles `visibleChange` events.
722 @method _afterVisibleChange
723 @param {EventFacade} e
726 _afterVisibleChange: function (e) {
727 this._syncVisibility(!!e.newVal);
731 Delegated event handler for item `click` events.
734 @param {EventFacade} e
737 _onItemClick: function (e) {
738 var itemNode = e.currentTarget;
740 this.set(ACTIVE_ITEM, itemNode);
741 this.selectItem(itemNode, e);
744 // -- Protected Default Event Handlers -------------------------------------
747 Default `select` event handler.
750 @param {EventFacade} e
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});
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
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
789 If `true`, the list will remain visible even when there are no results
792 @attribute alwaysShowList
801 If `true`, keyboard navigation will wrap around to the opposite end of
802 the list when navigating past the first or last item.
813 Item currently being hovered over by the mouse, if any.
815 @attribute hoveredItem
825 Node that will contain result items.
832 writeOnce: 'initOnly',
837 If `true`, the viewport will be scrolled to ensure that the active list
838 item is visible when necessary.
840 @attribute scrollIntoView
849 Translatable strings used by the AutoCompleteList widget.
855 valueFn: function () {
856 return Y.Intl.get('autocomplete-list');
861 If `true`, pressing the tab key while the list is visible will select
862 the active item, if any.
872 // The "visible" attribute is documented in Widget.
878 CSS_PREFIX: Y.ClassNameManager.getClassName('aclist')
881 Y.AutoCompleteList = List;
884 Alias for <a href="AutoCompleteList.html">`AutoCompleteList`</a>. See that class
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']});