/** Traditional autocomplete dropdown list widget, just like Mom used to make. @module autocomplete @submodule autocomplete-list **/ /** Traditional autocomplete dropdown list widget, just like Mom used to make. @class AutoCompleteList @extends Widget @uses AutoCompleteBase @uses WidgetPosition @uses WidgetPositionAlign @constructor @param {Object} config Configuration object. **/ var Lang = Y.Lang, Node = Y.Node, YArray = Y.Array, // Whether or not we need an iframe shim. useShim = Y.UA.ie && Y.UA.ie < 7, // keyCode constants. KEY_TAB = 9, // String shorthand. _CLASS_ITEM = '_CLASS_ITEM', _CLASS_ITEM_ACTIVE = '_CLASS_ITEM_ACTIVE', _CLASS_ITEM_HOVER = '_CLASS_ITEM_HOVER', _SELECTOR_ITEM = '_SELECTOR_ITEM', ACTIVE_ITEM = 'activeItem', ALWAYS_SHOW_LIST = 'alwaysShowList', CIRCULAR = 'circular', HOVERED_ITEM = 'hoveredItem', ID = 'id', ITEM = 'item', LIST = 'list', RESULT = 'result', RESULTS = 'results', VISIBLE = 'visible', WIDTH = 'width', // Event names. EVT_SELECT = 'select', List = Y.Base.create('autocompleteList', Y.Widget, [ Y.AutoCompleteBase, Y.WidgetPosition, Y.WidgetPositionAlign ], { // -- Prototype Properties ------------------------------------------------- ARIA_TEMPLATE: '<div/>', ITEM_TEMPLATE: '<li/>', LIST_TEMPLATE: '<ul/>', // Widget automatically attaches delegated event handlers to everything in // Y.Node.DOM_EVENTS, including synthetic events. Since Widget's event // delegation won't work for the synthetic valuechange event, and since // it creates a name collision between the backcompat "valueChange" synth // event alias and AutoCompleteList's "valueChange" event for the "value" // attr, this hack is necessary in order to prevent Widget from attaching // valuechange handlers. UI_EVENTS: (function () { var uiEvents = Y.merge(Y.Node.DOM_EVENTS); delete uiEvents.valuechange; delete uiEvents.valueChange; return uiEvents; }()), // -- Lifecycle Prototype Methods ------------------------------------------ initializer: function () { var inputNode = this.get('inputNode'); if (!inputNode) { Y.error('No inputNode specified.'); return; } this._inputNode = inputNode; this._listEvents = []; // This ensures that the list is rendered inside the same parent as the // input node by default, which is necessary for proper ARIA support. this.DEF_PARENT_NODE = inputNode.get('parentNode'); // Cache commonly used classnames and selectors for performance. this[_CLASS_ITEM] = this.getClassName(ITEM); this[_CLASS_ITEM_ACTIVE] = this.getClassName(ITEM, 'active'); this[_CLASS_ITEM_HOVER] = this.getClassName(ITEM, 'hover'); this[_SELECTOR_ITEM] = '.' + this[_CLASS_ITEM]; /** Fires when an autocomplete suggestion is selected from the list, typically via a keyboard action or mouse click. @event select @param {Node} itemNode List item node that was selected. @param {Object} result AutoComplete result object. @preventable _defSelectFn **/ this.publish(EVT_SELECT, { defaultFn: this._defSelectFn }); }, destructor: function () { while (this._listEvents.length) { this._listEvents.pop().detach(); } if (this._ariaNode) { this._ariaNode.remove().destroy(true); } }, bindUI: function () { this._bindInput(); this._bindList(); }, renderUI: function () { var ariaNode = this._createAriaNode(), boundingBox = this.get('boundingBox'), contentBox = this.get('contentBox'), inputNode = this._inputNode, listNode = this._createListNode(), parentNode = inputNode.get('parentNode'); inputNode.addClass(this.getClassName('input')).setAttrs({ 'aria-autocomplete': LIST, 'aria-expanded' : false, 'aria-owns' : listNode.get('id') }); // ARIA node must be outside the widget or announcements won't be made // when the widget is hidden. parentNode.append(ariaNode); // Add an iframe shim for IE6. if (useShim) { boundingBox.plug(Y.Plugin.Shim); } this._ariaNode = ariaNode; this._boundingBox = boundingBox; this._contentBox = contentBox; this._listNode = listNode; this._parentNode = parentNode; }, syncUI: function () { // No need to call _syncPosition() here; the other _sync methods will // call it when necessary. this._syncResults(); this._syncVisibility(); }, // -- Public Prototype Methods --------------------------------------------- /** Hides the list, unless the `alwaysShowList` attribute is `true`. @method hide @see show @chainable **/ hide: function () { return this.get(ALWAYS_SHOW_LIST) ? this : this.set(VISIBLE, false); }, /** Selects the specified _itemNode_, or the current `activeItem` if _itemNode_ is not specified. @method selectItem @param {Node} [itemNode] Item node to select. @param {EventFacade} [originEvent] Event that triggered the selection, if any. @chainable **/ selectItem: function (itemNode, originEvent) { if (itemNode) { if (!itemNode.hasClass(this[_CLASS_ITEM])) { return this; } } else { itemNode = this.get(ACTIVE_ITEM); if (!itemNode) { return this; } } this.fire(EVT_SELECT, { itemNode : itemNode, originEvent: originEvent || null, result : itemNode.getData(RESULT) }); return this; }, // -- Protected Prototype Methods ------------------------------------------ /** Activates the next item after the currently active item. If there is no next item and the `circular` attribute is `true`, focus will wrap back to the input node. @method _activateNextItem @chainable @protected **/ _activateNextItem: function () { var item = this.get(ACTIVE_ITEM), nextItem; if (item) { nextItem = item.next(this[_SELECTOR_ITEM]) || (this.get(CIRCULAR) ? null : item); } else { nextItem = this._getFirstItemNode(); } this.set(ACTIVE_ITEM, nextItem); return this; }, /** Activates the item previous to the currently active item. If there is no previous item and the `circular` attribute is `true`, focus will wrap back to the input node. @method _activatePrevItem @chainable @protected **/ _activatePrevItem: function () { var item = this.get(ACTIVE_ITEM), prevItem = item ? item.previous(this[_SELECTOR_ITEM]) : this.get(CIRCULAR) && this._getLastItemNode(); this.set(ACTIVE_ITEM, prevItem || null); return this; }, /** Appends the specified result _items_ to the list inside a new item node. @method _add @param {Array|Node|HTMLElement|String} items Result item or array of result items. @return {NodeList} Added nodes. @protected **/ _add: function (items) { var itemNodes = []; YArray.each(Lang.isArray(items) ? items : [items], function (item) { itemNodes.push(this._createItemNode(item).setData(RESULT, item)); }, this); itemNodes = Y.all(itemNodes); this._listNode.append(itemNodes.toFrag()); return itemNodes; }, /** Updates the ARIA live region with the specified message. @method _ariaSay @param {String} stringId String id (from the `strings` attribute) of the message to speak. @param {Object} [subs] Substitutions for placeholders in the string. @protected **/ _ariaSay: function (stringId, subs) { var message = this.get('strings.' + stringId); this._ariaNode.set('text', subs ? Lang.sub(message, subs) : message); }, /** Binds `inputNode` events and behavior. @method _bindInput @protected **/ _bindInput: function () { var inputNode = this._inputNode, alignNode, alignWidth, tokenInput; // Null align means we can auto-align. Set align to false to prevent // auto-alignment, or a valid alignment config to customize the // alignment. if (this.get('align') === null) { // If this is a tokenInput, align with its bounding box. // Otherwise, align with the inputNode. Bit of a cheat. tokenInput = this.get('tokenInput'); alignNode = (tokenInput && tokenInput.get('boundingBox')) || inputNode; this.set('align', { node : alignNode, points: ['tl', 'bl'] }); // If no width config is set, attempt to set the list's width to the // width of the alignment node. If the alignment node's width is // falsy, do nothing. if (!this.get(WIDTH) && (alignWidth = alignNode.get('offsetWidth'))) { this.set(WIDTH, alignWidth); } } // Attach inputNode events. this._listEvents = this._listEvents.concat([ inputNode.after('blur', this._afterListInputBlur, this), inputNode.after('focus', this._afterListInputFocus, this) ]); }, /** Binds list events. @method _bindList @protected **/ _bindList: function () { this._listEvents = this._listEvents.concat([ Y.one('doc').after('click', this._afterDocClick, this), Y.one('win').after('windowresize', this._syncPosition, this), this.after({ mouseover: this._afterMouseOver, mouseout : this._afterMouseOut, activeItemChange : this._afterActiveItemChange, alwaysShowListChange: this._afterAlwaysShowListChange, hoveredItemChange : this._afterHoveredItemChange, resultsChange : this._afterResultsChange, visibleChange : this._afterVisibleChange }), this._listNode.delegate('click', this._onItemClick, this[_SELECTOR_ITEM], this) ]); }, /** Clears the contents of the tray. @method _clear @protected **/ _clear: function () { this.set(ACTIVE_ITEM, null); this._set(HOVERED_ITEM, null); this._listNode.get('children').remove(true); }, /** Creates and returns an ARIA live region node. @method _createAriaNode @return {Node} ARIA node. @protected **/ _createAriaNode: function () { var ariaNode = Node.create(this.ARIA_TEMPLATE); return ariaNode.addClass(this.getClassName('aria')).setAttrs({ 'aria-live': 'polite', role : 'status' }); }, /** Creates and returns an item node with the specified _content_. @method _createItemNode @param {Object} result Result object. @return {Node} Item node. @protected **/ _createItemNode: function (result) { var itemNode = Node.create(this.ITEM_TEMPLATE); return itemNode.addClass(this[_CLASS_ITEM]).setAttrs({ id : Y.stamp(itemNode), role: 'option' }).setAttribute('data-text', result.text).append(result.display); }, /** Creates and returns a list node. If the `listNode` attribute is already set to an existing node, that node will be used. @method _createListNode @return {Node} List node. @protected **/ _createListNode: function () { var listNode = this.get('listNode') || Node.create(this.LIST_TEMPLATE); listNode.addClass(this.getClassName(LIST)).setAttrs({ id : Y.stamp(listNode), role: 'listbox' }); this._set('listNode', listNode); this.get('contentBox').append(listNode); return listNode; }, /** Gets the first item node in the list, or `null` if the list is empty. @method _getFirstItemNode @return {Node|null} @protected **/ _getFirstItemNode: function () { return this._listNode.one(this[_SELECTOR_ITEM]); }, /** Gets the last item node in the list, or `null` if the list is empty. @method _getLastItemNode @return {Node|null} @protected **/ _getLastItemNode: function () { return this._listNode.one(this[_SELECTOR_ITEM] + ':last-child'); }, /** Synchronizes the result list's position and alignment. @method _syncPosition @protected **/ _syncPosition: function () { // Force WidgetPositionAlign to refresh its alignment. this._syncUIPosAlign(); // Resize the IE6 iframe shim to match the list's dimensions. this._syncShim(); }, /** Synchronizes the results displayed in the list with those in the _results_ argument, or with the `results` attribute if an argument is not provided. @method _syncResults @param {Array} [results] Results. @protected **/ _syncResults: function (results) { if (!results) { results = this.get(RESULTS); } this._clear(); if (results.length) { this._add(results); this._ariaSay('items_available'); } this._syncPosition(); if (this.get('activateFirstItem') && !this.get(ACTIVE_ITEM)) { this.set(ACTIVE_ITEM, this._getFirstItemNode()); } }, /** Synchronizes the size of the iframe shim used for IE6 and lower. In other browsers, this method is a noop. @method _syncShim @protected **/ _syncShim: useShim ? function () { var shim = this._boundingBox.shim; if (shim) { shim.sync(); } } : function () {}, /** Synchronizes the visibility of the tray with the _visible_ argument, or with the `visible` attribute if an argument is not provided. @method _syncVisibility @param {Boolean} [visible] Visibility. @protected **/ _syncVisibility: function (visible) { if (this.get(ALWAYS_SHOW_LIST)) { visible = true; this.set(VISIBLE, visible); } if (typeof visible === 'undefined') { visible = this.get(VISIBLE); } this._inputNode.set('aria-expanded', visible); this._boundingBox.set('aria-hidden', !visible); if (visible) { this._syncPosition(); } else { this.set(ACTIVE_ITEM, null); this._set(HOVERED_ITEM, null); // Force a reflow to work around a glitch in IE6 and 7 where some of // the contents of the list will sometimes remain visible after the // container is hidden. this._boundingBox.get('offsetWidth'); } // In some pages, IE7 fails to repaint the contents of the list after it // becomes visible. Toggling a bogus class on the body forces a repaint // that fixes the issue. if (Y.UA.ie === 7) { // Note: We don't actually need to use ClassNameManager here. This // class isn't applying any actual styles; it's just frobbing the // body element to force a repaint. The actual class name doesn't // really matter. Y.one('body') .addClass('yui3-ie7-sucks') .removeClass('yui3-ie7-sucks'); } }, // -- Protected Event Handlers --------------------------------------------- /** Handles `activeItemChange` events. @method _afterActiveItemChange @param {EventFacade} e @protected **/ _afterActiveItemChange: function (e) { var inputNode = this._inputNode, newVal = e.newVal, prevVal = e.prevVal, node; // The previous item may have disappeared by the time this handler runs, // so we need to be careful. if (prevVal && prevVal._node) { prevVal.removeClass(this[_CLASS_ITEM_ACTIVE]); } if (newVal) { newVal.addClass(this[_CLASS_ITEM_ACTIVE]); inputNode.set('aria-activedescendant', newVal.get(ID)); } else { inputNode.removeAttribute('aria-activedescendant'); } if (this.get('scrollIntoView')) { node = newVal || inputNode; if (!node.inRegion(Y.DOM.viewportRegion(), true) || !node.inRegion(this._contentBox, true)) { node.scrollIntoView(); } } }, /** Handles `alwaysShowListChange` events. @method _afterAlwaysShowListChange @param {EventFacade} e @protected **/ _afterAlwaysShowListChange: function (e) { this.set(VISIBLE, e.newVal || this.get(RESULTS).length > 0); }, /** Handles click events on the document. If the click is outside both the input node and the bounding box, the list will be hidden. @method _afterDocClick @param {EventFacade} e @protected @since 3.5.0 **/ _afterDocClick: function (e) { var boundingBox = this._boundingBox, target = e.target; if (target !== this._inputNode && target !== boundingBox && !target.ancestor('#' + boundingBox.get('id'), true)){ this.hide(); } }, /** Handles `hoveredItemChange` events. @method _afterHoveredItemChange @param {EventFacade} e @protected **/ _afterHoveredItemChange: function (e) { var newVal = e.newVal, prevVal = e.prevVal; if (prevVal) { prevVal.removeClass(this[_CLASS_ITEM_HOVER]); } if (newVal) { newVal.addClass(this[_CLASS_ITEM_HOVER]); } }, /** Handles `inputNode` blur events. @method _afterListInputBlur @protected **/ _afterListInputBlur: function () { this._listInputFocused = false; if (this.get(VISIBLE) && !this._mouseOverList && (this._lastInputKey !== KEY_TAB || !this.get('tabSelect') || !this.get(ACTIVE_ITEM))) { this.hide(); } }, /** Handles `inputNode` focus events. @method _afterListInputFocus @protected **/ _afterListInputFocus: function () { this._listInputFocused = true; }, /** Handles `mouseover` events. @method _afterMouseOver @param {EventFacade} e @protected **/ _afterMouseOver: function (e) { var itemNode = e.domEvent.target.ancestor(this[_SELECTOR_ITEM], true); this._mouseOverList = true; if (itemNode) { this._set(HOVERED_ITEM, itemNode); } }, /** Handles `mouseout` events. @method _afterMouseOut @param {EventFacade} e @protected **/ _afterMouseOut: function () { this._mouseOverList = false; this._set(HOVERED_ITEM, null); }, /** Handles `resultsChange` events. @method _afterResultsChange @param {EventFacade} e @protected **/ _afterResultsChange: function (e) { this._syncResults(e.newVal); if (!this.get(ALWAYS_SHOW_LIST)) { this.set(VISIBLE, !!e.newVal.length); } }, /** Handles `visibleChange` events. @method _afterVisibleChange @param {EventFacade} e @protected **/ _afterVisibleChange: function (e) { this._syncVisibility(!!e.newVal); }, /** Delegated event handler for item `click` events. @method _onItemClick @param {EventFacade} e @protected **/ _onItemClick: function (e) { var itemNode = e.currentTarget; this.set(ACTIVE_ITEM, itemNode); this.selectItem(itemNode, e); }, // -- Protected Default Event Handlers ------------------------------------- /** Default `select` event handler. @method _defSelectFn @param {EventFacade} e @protected **/ _defSelectFn: function (e) { var text = e.result.text; // TODO: support typeahead completion, etc. this._inputNode.focus(); this._updateValue(text); this._ariaSay('item_selected', {item: text}); this.hide(); } }, { ATTRS: { /** If `true`, the first item in the list will be activated by default when the list is initially displayed and when results change. @attribute activateFirstItem @type Boolean @default false **/ activateFirstItem: { value: false }, /** Item that's currently active, if any. When the user presses enter, this is the item that will be selected. @attribute activeItem @type Node **/ activeItem: { setter: Y.one, value: null }, /** If `true`, the list will remain visible even when there are no results to display. @attribute alwaysShowList @type Boolean @default false **/ alwaysShowList: { value: false }, /** If `true`, keyboard navigation will wrap around to the opposite end of the list when navigating past the first or last item. @attribute circular @type Boolean @default true **/ circular: { value: true }, /** Item currently being hovered over by the mouse, if any. @attribute hoveredItem @type Node|null @readOnly **/ hoveredItem: { readOnly: true, value: null }, /** Node that will contain result items. @attribute listNode @type Node|null @initOnly **/ listNode: { writeOnce: 'initOnly', value: null }, /** If `true`, the viewport will be scrolled to ensure that the active list item is visible when necessary. @attribute scrollIntoView @type Boolean @default false **/ scrollIntoView: { value: false }, /** Translatable strings used by the AutoCompleteList widget. @attribute strings @type Object **/ strings: { valueFn: function () { return Y.Intl.get('autocomplete-list'); } }, /** If `true`, pressing the tab key while the list is visible will select the active item, if any. @attribute tabSelect @type Boolean @default true **/ tabSelect: { value: true }, // The "visible" attribute is documented in Widget. visible: { value: false } }, CSS_PREFIX: Y.ClassNameManager.getClassName('aclist') }); Y.AutoCompleteList = List; /** Alias for <a href="AutoCompleteList.html">`AutoCompleteList`</a>. See that class for API docs. @class AutoComplete **/ Y.AutoComplete = List;