2 Copyright (c) 2010, Yahoo! Inc. All rights reserved.
3 Code licensed under the BSD License:
4 http://developer.yahoo.com/yui/license.html
8 YUI.add('autocomplete-list', function(Y) {
11 * Traditional autocomplete dropdown list widget, just like Mom used to make.
13 * @module autocomplete
14 * @submodule autocomplete-list
15 * @class AutoCompleteList
17 * @uses AutoCompleteBase
18 * @uses WidgetPosition
19 * @uses WidgetPositionAlign
22 * @param {Object} config Configuration object.
33 _CLASS_ITEM = '_CLASS_ITEM',
34 _CLASS_ITEM_ACTIVE = '_CLASS_ITEM_ACTIVE',
35 _CLASS_ITEM_HOVER = '_CLASS_ITEM_HOVER',
36 _SELECTOR_ITEM = '_SELECTOR_ITEM',
38 ACTIVE_ITEM = 'activeItem',
39 ALWAYS_SHOW_LIST = 'alwaysShowList',
40 CIRCULAR = 'circular',
41 HOVERED_ITEM = 'hoveredItem',
51 EVT_SELECT = 'select',
53 List = Y.Base.create('autocompleteList', Y.Widget, [
56 Y.WidgetPositionAlign,
59 // -- Prototype Properties -------------------------------------------------
60 ARIA_TEMPLATE: '<div/>',
61 ITEM_TEMPLATE: '<li/>',
62 LIST_TEMPLATE: '<ul/>',
64 // -- Lifecycle Prototype Methods ------------------------------------------
65 initializer: function () {
66 var inputNode = this.get('inputNode');
69 Y.error('No inputNode specified.');
73 this._inputNode = inputNode;
74 this._listEvents = [];
76 // This ensures that the list is rendered inside the same parent as the
77 // input node by default, which is necessary for proper ARIA support.
78 this.DEF_PARENT_NODE = inputNode.get('parentNode');
80 // Cache commonly used classnames and selectors for performance.
81 this[_CLASS_ITEM] = this.getClassName(ITEM);
82 this[_CLASS_ITEM_ACTIVE] = this.getClassName(ITEM, 'active');
83 this[_CLASS_ITEM_HOVER] = this.getClassName(ITEM, 'hover');
84 this[_SELECTOR_ITEM] = '.' + this[_CLASS_ITEM];
87 * Fires when an autocomplete suggestion is selected from the list,
88 * typically via a keyboard action or mouse click.
91 * @param {EventFacade} e Event facade with the following additional
95 * <dt>itemNode (Node)</dt>
97 * List item node that was selected.
100 * <dt>result (Object)</dt>
102 * AutoComplete result object.
106 * @preventable _defSelectFn
108 this.publish(EVT_SELECT, {
109 defaultFn: this._defSelectFn
113 destructor: function () {
114 while (this._listEvents.length) {
115 this._listEvents.pop().detach();
119 bindUI: function () {
124 renderUI: function () {
125 var ariaNode = this._createAriaNode(),
126 contentBox = this.get('contentBox'),
127 inputNode = this._inputNode,
129 parentNode = inputNode.get('parentNode');
131 listNode = this._createListNode();
132 this._set('listNode', listNode);
133 contentBox.append(listNode);
135 inputNode.addClass(this.getClassName('input')).setAttrs({
136 'aria-autocomplete': LIST,
137 'aria-expanded' : false,
138 'aria-owns' : listNode.get('id'),
142 // ARIA node must be outside the widget or announcements won't be made
143 // when the widget is hidden.
144 parentNode.append(ariaNode);
146 this._ariaNode = ariaNode;
147 this._boundingBox = this.get('boundingBox');
148 this._contentBox = contentBox;
149 this._listNode = listNode;
150 this._parentNode = parentNode;
153 syncUI: function () {
155 this._syncVisibility();
158 // -- Public Prototype Methods ---------------------------------------------
161 * Hides the list, unless the <code>alwaysShowList</code> attribute is
169 return this.get(ALWAYS_SHOW_LIST) ? this : this.set(VISIBLE, false);
173 * Selects the specified <i>itemNode</i>, or the current
174 * <code>activeItem</code> if <i>itemNode</i> is not specified.
177 * @param {Node} itemNode (optional) Item node to select.
180 selectItem: function (itemNode) {
182 if (!itemNode.hasClass(this[_CLASS_ITEM])) {
186 itemNode = this.get(ACTIVE_ITEM);
193 this.fire(EVT_SELECT, {
195 result : itemNode.getData(RESULT)
201 // -- Protected Prototype Methods ------------------------------------------
204 * Activates the next item after the currently active item. If there is no
205 * next item and the <code>circular</code> attribute is <code>true</code>,
206 * focus will wrap back to the input node.
208 * @method _activateNextItem
212 _activateNextItem: function () {
213 var item = this.get(ACTIVE_ITEM),
217 nextItem = item.next(this[_SELECTOR_ITEM]) ||
218 (this.get(CIRCULAR) ? null : item);
220 nextItem = this._getFirstItemNode();
223 this.set(ACTIVE_ITEM, nextItem);
229 * Activates the item previous to the currently active item. If there is no
230 * previous item and the <code>circular</code> attribute is
231 * <code>true</code>, focus will wrap back to the input node.
233 * @method _activatePrevItem
237 _activatePrevItem: function () {
238 var item = this.get(ACTIVE_ITEM),
239 prevItem = item ? item.previous(this[_SELECTOR_ITEM]) :
240 this.get(CIRCULAR) && this._getLastItemNode();
242 this.set(ACTIVE_ITEM, prevItem || null);
248 * Appends the specified result <i>items</i> to the list inside a new item
252 * @param {Array|Node|HTMLElement|String} items Result item or array of
254 * @return {NodeList} Added nodes.
257 _add: function (items) {
260 YArray.each(Lang.isArray(items) ? items : [items], function (item) {
261 itemNodes.push(this._createItemNode(item).setData(RESULT, item));
264 itemNodes = Y.all(itemNodes);
265 this._listNode.append(itemNodes.toFrag());
271 * Updates the ARIA live region with the specified message.
274 * @param {String} stringId String id (from the <code>strings</code>
275 * attribute) of the message to speak.
276 * @param {Object} subs (optional) Substitutions for placeholders in the
280 _ariaSay: function (stringId, subs) {
281 var message = this.get('strings.' + stringId);
282 this._ariaNode.setContent(subs ? Lang.sub(message, subs) : message);
286 * Binds <code>inputNode</code> events and behavior.
291 _bindInput: function () {
292 var inputNode = this._inputNode,
293 alignNode, alignWidth, tokenInput;
295 // Null align means we can auto-align. Set align to false to prevent
296 // auto-alignment, or a valid alignment config to customize the
298 if (this.get('align') === null) {
299 // If this is a tokenInput, align with its bounding box.
300 // Otherwise, align with the inputNode. Bit of a cheat.
301 tokenInput = this.get('tokenInput');
302 alignNode = (tokenInput && tokenInput.get('boundingBox')) || inputNode;
309 // If no width config is set, attempt to set the list's width to the
310 // width of the alignment node. If the alignment node's width is
311 // falsy, do nothing.
312 if (!this.get(WIDTH) && (alignWidth = alignNode.get('offsetWidth'))) {
313 this.set(WIDTH, alignWidth);
317 // Attach inputNode events.
318 this._listEvents.push(inputNode.on('blur', this._onListInputBlur, this));
327 _bindList: function () {
328 this._listEvents.concat([
330 mouseover: this._afterMouseOver,
331 mouseout : this._afterMouseOut,
333 activeItemChange : this._afterActiveItemChange,
334 alwaysShowListChange: this._afterAlwaysShowListChange,
335 hoveredItemChange : this._afterHoveredItemChange,
336 resultsChange : this._afterResultsChange,
337 visibleChange : this._afterVisibleChange
340 this._listNode.delegate('click', this._onItemClick, this[_SELECTOR_ITEM], this)
345 * Clears the contents of the tray.
350 _clear: function () {
351 this.set(ACTIVE_ITEM, null);
352 this._set(HOVERED_ITEM, null);
354 this._listNode.get('children').remove(true);
358 * Creates and returns an ARIA live region node.
360 * @method _createAriaNode
361 * @return {Node} ARIA node.
364 _createAriaNode: function () {
365 var ariaNode = Node.create(this.ARIA_TEMPLATE);
367 return ariaNode.addClass(this.getClassName('aria')).setAttrs({
368 'aria-live': 'polite',
374 * Creates and returns an item node with the specified <i>content</i>.
376 * @method _createItemNode
377 * @param {Object} result Result object.
378 * @return {Node} Item node.
381 _createItemNode: function (result) {
382 var itemNode = Node.create(this.ITEM_TEMPLATE);
384 return itemNode.addClass(this[_CLASS_ITEM]).setAttrs({
385 id : Y.stamp(itemNode),
387 }).setAttribute('data-text', result.text).append(result.display);
391 * Creates and returns a list node.
393 * @method _createListNode
394 * @return {Node} List node.
397 _createListNode: function () {
398 var listNode = Node.create(this.LIST_TEMPLATE);
400 return listNode.addClass(this.getClassName(LIST)).setAttrs({
401 id : Y.stamp(listNode),
407 * Gets the first item node in the list, or <code>null</code> if the list is
410 * @method _getFirstItemNode
411 * @return {Node|null}
414 _getFirstItemNode: function () {
415 return this._listNode.one(this[_SELECTOR_ITEM]);
419 * Gets the last item node in the list, or <code>null</code> if the list is
422 * @method _getLastItemNode
423 * @return {Node|null}
426 _getLastItemNode: function () {
427 return this._listNode.one(this[_SELECTOR_ITEM] + ':last-child');
431 * Synchronizes the results displayed in the list with those in the
432 * <i>results</i> argument, or with the <code>results</code> attribute if an
433 * argument is not provided.
435 * @method _syncResults
436 * @param {Array} results (optional) Results.
439 _syncResults: function (results) {
443 results = this.get(RESULTS);
448 if (results.length) {
449 items = this._add(results);
450 this._ariaSay('items_available');
453 if (this.get('activateFirstItem') && !this.get(ACTIVE_ITEM)) {
454 this.set(ACTIVE_ITEM, this._getFirstItemNode());
459 * Synchronizes the visibility of the tray with the <i>visible</i> argument,
460 * or with the <code>visible</code> attribute if an argument is not
463 * @method _syncVisibility
464 * @param {Boolean} visible (optional) Visibility.
467 _syncVisibility: function (visible) {
468 if (this.get(ALWAYS_SHOW_LIST)) {
470 this.set(VISIBLE, visible);
473 if (typeof visible === 'undefined') {
474 visible = this.get(VISIBLE);
477 this._inputNode.set('aria-expanded', visible);
478 this._boundingBox.set('aria-hidden', !visible);
481 // Force WidgetPositionAlign to refresh its alignment.
482 this._syncUIPosAlign();
484 this.set(ACTIVE_ITEM, null);
485 this._set(HOVERED_ITEM, null);
487 // Force a reflow to work around a glitch in IE6 and 7 where some of
488 // the contents of the list will sometimes remain visible after the
489 // container is hidden.
490 this._boundingBox.get('offsetWidth');
494 // -- Protected Event Handlers ---------------------------------------------
497 * Handles <code>activeItemChange</code> events.
499 * @method _afterActiveItemChange
500 * @param {EventTarget} e
503 _afterActiveItemChange: function (e) {
504 var inputNode = this._inputNode,
508 // The previous item may have disappeared by the time this handler runs,
509 // so we need to be careful.
510 if (prevVal && prevVal._node) {
511 prevVal.removeClass(this[_CLASS_ITEM_ACTIVE]);
515 newVal.addClass(this[_CLASS_ITEM_ACTIVE]);
516 inputNode.set('aria-activedescendant', newVal.get(ID));
518 inputNode.removeAttribute('aria-activedescendant');
521 if (this.get('scrollIntoView')) {
522 (newVal || inputNode).scrollIntoView();
527 * Handles <code>alwaysShowListChange</code> events.
529 * @method _afterAlwaysShowListChange
530 * @param {EventTarget} e
533 _afterAlwaysShowListChange: function (e) {
534 this.set(VISIBLE, e.newVal || this.get(RESULTS).length > 0);
538 * Handles <code>hoveredItemChange</code> events.
540 * @method _afterHoveredItemChange
541 * @param {EventTarget} e
544 _afterHoveredItemChange: function (e) {
545 var newVal = e.newVal,
549 prevVal.removeClass(this[_CLASS_ITEM_HOVER]);
553 newVal.addClass(this[_CLASS_ITEM_HOVER]);
558 * Handles <code>mouseover</code> events.
560 * @method _afterMouseOver
561 * @param {EventTarget} e
564 _afterMouseOver: function (e) {
565 var itemNode = e.domEvent.target.ancestor(this[_SELECTOR_ITEM], true);
567 this._mouseOverList = true;
570 this._set(HOVERED_ITEM, itemNode);
575 * Handles <code>mouseout</code> events.
577 * @method _afterMouseOut
578 * @param {EventTarget} e
581 _afterMouseOut: function () {
582 this._mouseOverList = false;
583 this._set(HOVERED_ITEM, null);
587 * Handles <code>resultsChange</code> events.
589 * @method _afterResultsChange
590 * @param {EventFacade} e
593 _afterResultsChange: function (e) {
594 this._syncResults(e.newVal);
596 if (!this.get(ALWAYS_SHOW_LIST)) {
597 this.set(VISIBLE, !!e.newVal.length);
602 * Handles <code>visibleChange</code> events.
604 * @method _afterVisibleChange
605 * @param {EventFacade} e
608 _afterVisibleChange: function (e) {
609 this._syncVisibility(!!e.newVal);
613 * Handles <code>inputNode</code> <code>blur</code> events.
615 * @method _onListInputBlur
616 * @param {EventTarget} e
619 _onListInputBlur: function (e) {
620 // Hide the list on inputNode blur events, unless the mouse is currently
621 // over the list (which indicates that the user is probably interacting
622 // with it). The _lastInputKey property comes from the
623 // autocomplete-list-keys module.
624 if (!this._mouseOverList || this._lastInputKey === KEY_TAB) {
630 * Delegated event handler for item <code>click</code> events.
632 * @method _onItemClick
633 * @param {EventTarget} e
636 _onItemClick: function (e) {
637 var itemNode = e.currentTarget;
639 this.set(ACTIVE_ITEM, itemNode);
640 this.selectItem(itemNode);
643 // -- Protected Default Event Handlers -------------------------------------
646 * Default <code>select</code> event handler.
648 * @method _defSelectFn
649 * @param {EventTarget} e
652 _defSelectFn: function (e) {
653 var text = e.result.text;
655 // TODO: support typeahead completion, etc.
656 this._inputNode.focus();
657 this._updateValue(text);
658 this._ariaSay('item_selected', {item: text});
664 * If <code>true</code>, the first item in the list will be activated by
665 * default when the list is initially displayed and when results change.
667 * @attribute activateFirstItem
676 * Item that's currently active, if any. When the user presses enter,
677 * this is the item that will be selected.
679 * @attribute activeItem
688 * If <code>true</code>, the list will remain visible even when there
689 * are no results to display.
691 * @attribute alwaysShowList
700 * If <code>true</code>, keyboard navigation will wrap around to the
701 * opposite end of the list when navigating past the first or last item.
703 * @attribute circular
712 * Item currently being hovered over by the mouse, if any.
714 * @attribute hoveredItem
724 * Node that will contain result items.
726 * @attribute listNode
736 * If <code>true</code>, the viewport will be scrolled to ensure that
737 * the active list item is visible when necessary.
739 * @attribute scrollIntoView
748 * Translatable strings used by the AutoCompleteList widget.
754 valueFn: function () {
755 return Y.Intl.get('autocomplete-list');
760 * If <code>true</code>, pressing the tab key while the list is visible
761 * will select the active item, if any.
763 * @attribute tabSelect
771 // The "visible" attribute is documented in Widget.
777 CSS_PREFIX: Y.ClassNameManager.getClassName('aclist')
780 Y.AutoCompleteList = List;
783 * Alias for <a href="AutoCompleteList.html"><code>AutoCompleteList</code></a>.
784 * See that class for API docs.
786 * @class AutoComplete
789 Y.AutoComplete = List;
792 }, '3.3.0' ,{lang:['en'], requires:['autocomplete-base', 'selector-css3', 'widget', 'widget-position', 'widget-position-align', 'widget-stack'], after:['autocomplete-sources'], skinnable:true});