UNPKG

accessibility-developer-tools

Version:

This is a library of accessibility-related testing and utility code.

646 lines (535 loc) 19 kB
// Copyright 2007 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview Menu where items can be filtered based on user keyboard input. * If a filter is specified only the items matching it will be displayed. * * @author eae@google.com (Emil A Eklund) * @see ../demos/filteredmenu.html */ goog.provide('goog.ui.FilteredMenu'); goog.require('goog.a11y.aria'); goog.require('goog.a11y.aria.AutoCompleteValues'); goog.require('goog.a11y.aria.State'); goog.require('goog.dom'); goog.require('goog.dom.InputType'); goog.require('goog.dom.TagName'); goog.require('goog.events'); goog.require('goog.events.EventType'); goog.require('goog.events.InputHandler'); goog.require('goog.events.KeyCodes'); goog.require('goog.object'); goog.require('goog.string'); goog.require('goog.style'); goog.require('goog.ui.Component'); goog.require('goog.ui.FilterObservingMenuItem'); goog.require('goog.ui.Menu'); goog.require('goog.ui.MenuItem'); goog.require('goog.userAgent'); /** * Filtered menu class. * @param {goog.ui.MenuRenderer=} opt_renderer Renderer used to render filtered * menu; defaults to {@link goog.ui.MenuRenderer}. * @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper. * @constructor * @extends {goog.ui.Menu} */ goog.ui.FilteredMenu = function(opt_renderer, opt_domHelper) { goog.ui.Menu.call(this, opt_domHelper, opt_renderer); }; goog.inherits(goog.ui.FilteredMenu, goog.ui.Menu); goog.tagUnsealableClass(goog.ui.FilteredMenu); /** * Events fired by component. * @enum {string} */ goog.ui.FilteredMenu.EventType = { /** Dispatched after the component filter criteria has been changed. */ FILTER_CHANGED: 'filterchange' }; /** * Filter menu element ids. * @enum {string} * @private */ goog.ui.FilteredMenu.Id_ = { CONTENT_ELEMENT: 'content-el' }; /** * Filter input element. * @type {Element|undefined} * @private */ goog.ui.FilteredMenu.prototype.filterInput_; /** * The input handler that provides the input event. * @type {goog.events.InputHandler|undefined} * @private */ goog.ui.FilteredMenu.prototype.inputHandler_; /** * Maximum number of characters for filter input. * @type {number} * @private */ goog.ui.FilteredMenu.prototype.maxLength_ = 0; /** * Label displayed in the filter input when no text has been entered. * @type {string} * @private */ goog.ui.FilteredMenu.prototype.label_ = ''; /** * Label element. * @type {Element|undefined} * @private */ goog.ui.FilteredMenu.prototype.labelEl_; /** * Whether multiple items can be entered comma separated. * @type {boolean} * @private */ goog.ui.FilteredMenu.prototype.allowMultiple_ = false; /** * List of items entered in the search box if multiple entries are allowed. * @type {Array<string>|undefined} * @private */ goog.ui.FilteredMenu.prototype.enteredItems_; /** * Index of first item that should be affected by the filter. Menu items with * a lower index will not be affected by the filter. * @type {number} * @private */ goog.ui.FilteredMenu.prototype.filterFromIndex_ = 0; /** * Filter applied to the menu. * @type {string|undefined|null} * @private */ goog.ui.FilteredMenu.prototype.filterStr_; /** * @private {Element} */ goog.ui.FilteredMenu.prototype.contentElement_; /** * Map of child nodes that shouldn't be affected by filtering. * @type {Object|undefined} * @private */ goog.ui.FilteredMenu.prototype.persistentChildren_; /** @override */ goog.ui.FilteredMenu.prototype.createDom = function() { goog.ui.FilteredMenu.superClass_.createDom.call(this); var dom = this.getDomHelper(); var el = dom.createDom( goog.dom.TagName.DIV, goog.getCssName(this.getRenderer().getCssClass(), 'filter'), this.labelEl_ = dom.createDom(goog.dom.TagName.DIV, null, this.label_), this.filterInput_ = dom.createDom( goog.dom.TagName.INPUT, {'type': goog.dom.InputType.TEXT})); var element = this.getElement(); dom.appendChild(element, el); var contentElementId = this.makeId(goog.ui.FilteredMenu.Id_.CONTENT_ELEMENT); this.contentElement_ = dom.createDom( goog.dom.TagName.DIV, goog.object.create( 'class', goog.getCssName(this.getRenderer().getCssClass(), 'content'), 'id', contentElementId)); dom.appendChild(element, this.contentElement_); this.initFilterInput_(); goog.a11y.aria.setState( this.filterInput_, goog.a11y.aria.State.AUTOCOMPLETE, goog.a11y.aria.AutoCompleteValues.LIST); goog.a11y.aria.setState( this.filterInput_, goog.a11y.aria.State.OWNS, contentElementId); goog.a11y.aria.setState( this.filterInput_, goog.a11y.aria.State.EXPANDED, true); }; /** * Helper method that initializes the filter input element. * @private */ goog.ui.FilteredMenu.prototype.initFilterInput_ = function() { this.setFocusable(true); this.setKeyEventTarget(this.filterInput_); // Workaround for mozilla bug #236791. if (goog.userAgent.GECKO) { this.filterInput_.setAttribute('autocomplete', 'off'); } if (this.maxLength_) { this.filterInput_.maxLength = this.maxLength_; } }; /** * Sets up listeners and prepares the filter functionality. * @private */ goog.ui.FilteredMenu.prototype.setUpFilterListeners_ = function() { if (!this.inputHandler_ && this.filterInput_) { this.inputHandler_ = new goog.events.InputHandler( /** @type {Element} */ (this.filterInput_)); goog.style.setUnselectable(this.filterInput_, false); goog.events.listen( this.inputHandler_, goog.events.InputHandler.EventType.INPUT, this.handleFilterEvent, false, this); goog.events.listen( this.filterInput_.parentNode, goog.events.EventType.CLICK, this.onFilterLabelClick_, false, this); if (this.allowMultiple_) { this.enteredItems_ = []; } } }; /** * Tears down listeners and resets the filter functionality. * @private */ goog.ui.FilteredMenu.prototype.tearDownFilterListeners_ = function() { if (this.inputHandler_) { goog.events.unlisten( this.inputHandler_, goog.events.InputHandler.EventType.INPUT, this.handleFilterEvent, false, this); goog.events.unlisten( this.filterInput_.parentNode, goog.events.EventType.CLICK, this.onFilterLabelClick_, false, this); this.inputHandler_.dispose(); this.inputHandler_ = undefined; this.enteredItems_ = undefined; } }; /** @override */ goog.ui.FilteredMenu.prototype.setVisible = function(show, opt_force, opt_e) { var visibilityChanged = goog.ui.FilteredMenu.superClass_.setVisible.call( this, show, opt_force, opt_e); if (visibilityChanged && show && this.isInDocument()) { this.setFilter(''); this.setUpFilterListeners_(); } else if (visibilityChanged && !show) { this.tearDownFilterListeners_(); } return visibilityChanged; }; /** @override */ goog.ui.FilteredMenu.prototype.disposeInternal = function() { this.tearDownFilterListeners_(); this.filterInput_ = undefined; this.labelEl_ = undefined; goog.ui.FilteredMenu.superClass_.disposeInternal.call(this); }; /** * Sets the filter label (the label displayed in the filter input element if no * text has been entered). * @param {?string} label Label text. */ goog.ui.FilteredMenu.prototype.setFilterLabel = function(label) { this.label_ = label || ''; if (this.labelEl_) { goog.dom.setTextContent(this.labelEl_, this.label_); } }; /** * @return {string} The filter label. */ goog.ui.FilteredMenu.prototype.getFilterLabel = function() { return this.label_; }; /** * Sets the filter string. * @param {?string} str Filter string. */ goog.ui.FilteredMenu.prototype.setFilter = function(str) { if (this.filterInput_) { this.filterInput_.value = str; this.filterItems_(str); } }; /** * Returns the filter string. * @return {string} Current filter or an an empty string. */ goog.ui.FilteredMenu.prototype.getFilter = function() { return this.filterInput_ && goog.isString(this.filterInput_.value) ? this.filterInput_.value : ''; }; /** * Sets the index of first item that should be affected by the filter. Menu * items with a lower index will not be affected by the filter. * @param {number} index Index of first item that should be affected by filter. */ goog.ui.FilteredMenu.prototype.setFilterFromIndex = function(index) { this.filterFromIndex_ = index; }; /** * Returns the index of first item that is affected by the filter. * @return {number} Index of first item that is affected by filter. */ goog.ui.FilteredMenu.prototype.getFilterFromIndex = function() { return this.filterFromIndex_; }; /** * Gets a list of items entered in the search box. * @return {!Array<string>} The entered items. */ goog.ui.FilteredMenu.prototype.getEnteredItems = function() { return this.enteredItems_ || []; }; /** * Sets whether multiple items can be entered comma separated. * @param {boolean} b Whether multiple items can be entered. */ goog.ui.FilteredMenu.prototype.setAllowMultiple = function(b) { this.allowMultiple_ = b; }; /** * @return {boolean} Whether multiple items can be entered comma separated. */ goog.ui.FilteredMenu.prototype.getAllowMultiple = function() { return this.allowMultiple_; }; /** * Sets whether the specified child should be affected (shown/hidden) by the * filter criteria. * @param {goog.ui.Component} child Child to change. * @param {boolean} persistent Whether the child should be persistent. */ goog.ui.FilteredMenu.prototype.setPersistentVisibility = function( child, persistent) { if (!this.persistentChildren_) { this.persistentChildren_ = {}; } this.persistentChildren_[child.getId()] = persistent; }; /** * Returns whether the specified child should be affected (shown/hidden) by the * filter criteria. * @param {goog.ui.Component} child Menu item to check. * @return {boolean} Whether the menu item is persistent. */ goog.ui.FilteredMenu.prototype.hasPersistentVisibility = function(child) { return !!( this.persistentChildren_ && this.persistentChildren_[child.getId()]); }; /** * Handles filter input events. * @param {goog.events.BrowserEvent} e The event object. */ goog.ui.FilteredMenu.prototype.handleFilterEvent = function(e) { this.filterItems_(this.filterInput_.value); // Highlight the first visible item unless there's already a highlighted item. var highlighted = this.getHighlighted(); if (!highlighted || !highlighted.isVisible()) { this.highlightFirst(); } this.dispatchEvent(goog.ui.FilteredMenu.EventType.FILTER_CHANGED); }; /** * Shows/hides elements based on the supplied filter. * @param {?string} str Filter string. * @private */ goog.ui.FilteredMenu.prototype.filterItems_ = function(str) { // Do nothing unless the filter string has changed. if (this.filterStr_ == str) { return; } if (this.labelEl_) { this.labelEl_.style.visibility = str == '' ? 'visible' : 'hidden'; } if (this.allowMultiple_ && this.enteredItems_) { // Matches all non space characters after the last comma. var lastWordRegExp = /^(.+),[ ]*([^,]*)$/; var matches = str.match(lastWordRegExp); // matches[1] is the string up to, but not including, the last comma and // matches[2] the part after the last comma. If there are no non-space // characters after the last comma matches[2] is undefined. var items = matches && matches[1] ? matches[1].split(',') : []; // If the number of comma separated items has changes recreate the // entered items array and fire a change event. if (str.substr(str.length - 1, 1) == ',' || items.length != this.enteredItems_.length) { var lastItem = items[items.length - 1] || ''; // Auto complete text in input box based on the highlighted item. if (this.getHighlighted() && lastItem != '') { var caption = this.getHighlighted().getCaption(); if (caption.toLowerCase().indexOf(lastItem.toLowerCase()) == 0) { items[items.length - 1] = caption; this.filterInput_.value = items.join(',') + ','; } } this.enteredItems_ = items; this.dispatchEvent(goog.ui.Component.EventType.CHANGE); this.setHighlightedIndex(-1); } if (matches) { str = matches.length > 2 ? goog.string.trim(matches[2]) : ''; } } var matcher = new RegExp('(^|[- ,_/.:])' + goog.string.regExpEscape(str), 'i'); for (var child, i = this.filterFromIndex_; child = this.getChildAt(i); i++) { if (child instanceof goog.ui.FilterObservingMenuItem) { child.callObserver(str); } else if (!this.hasPersistentVisibility(child)) { // Only show items matching the filter and highlight the part of the // caption that matches. var caption = child.getCaption(); if (caption) { var matchArray = caption.match(matcher); if (str == '' || matchArray) { child.setVisible(true); var pos = caption.indexOf(matchArray[0]); // If position is non zero increase by one to skip the separator. if (pos) { pos++; } this.boldContent_(child, pos, str.length); } else { child.setVisible(false); } } else { // Hide separators and other items without a caption if a filter string // has been entered. child.setVisible(str == ''); } } } this.filterStr_ = str; }; /** * Updates the content of the given menu item, bolding the part of its caption * from start and through the next len characters. * @param {!goog.ui.Control} child The control to bold content on. * @param {number} start The index at which to start bolding. * @param {number} len How many characters to bold. * @private */ goog.ui.FilteredMenu.prototype.boldContent_ = function(child, start, len) { var caption = child.getCaption(); var boldedCaption; if (len == 0) { boldedCaption = this.getDomHelper().createTextNode(caption); } else { var preMatch = caption.substr(0, start); var match = caption.substr(start, len); var postMatch = caption.substr(start + len); boldedCaption = this.getDomHelper().createDom( goog.dom.TagName.SPAN, null, preMatch, this.getDomHelper().createDom(goog.dom.TagName.B, null, match), postMatch); } var accelerator = child.getAccelerator && child.getAccelerator(); if (accelerator) { child.setContent([ boldedCaption, this.getDomHelper().createDom( goog.dom.TagName.SPAN, goog.ui.MenuItem.ACCELERATOR_CLASS, accelerator) ]); } else { child.setContent(boldedCaption); } }; /** * Handles the menu's behavior for a key event. The highlighted menu item will * be given the opportunity to handle the key behavior. * @param {goog.events.KeyEvent} e A browser event. * @return {boolean} Whether the event was handled. * @override */ goog.ui.FilteredMenu.prototype.handleKeyEventInternal = function(e) { // Home, end and the arrow keys are normally used to change the selected menu // item. Return false here to prevent the menu from preventing the default // behavior for HOME, END and any key press with a modifier. if (e.shiftKey || e.ctrlKey || e.altKey || e.keyCode == goog.events.KeyCodes.HOME || e.keyCode == goog.events.KeyCodes.END) { return false; } if (e.keyCode == goog.events.KeyCodes.ESC) { this.dispatchEvent(goog.ui.Component.EventType.BLUR); return true; } return goog.ui.FilteredMenu.superClass_.handleKeyEventInternal.call(this, e); }; /** * Sets the highlighted index, unless the HIGHLIGHT event is intercepted and * cancelled. -1 = no highlight. Also scrolls the menu item into view. * @param {number} index Index of menu item to highlight. * @override */ goog.ui.FilteredMenu.prototype.setHighlightedIndex = function(index) { goog.ui.FilteredMenu.superClass_.setHighlightedIndex.call(this, index); var contentEl = this.getContentElement(); var el = /** @type {!HTMLElement} */ ( this.getHighlighted() ? this.getHighlighted().getElement() : null); if (this.filterInput_) { goog.a11y.aria.setActiveDescendant(this.filterInput_, el); } if (el && goog.dom.contains(contentEl, el)) { var contentTop = goog.userAgent.IE && !goog.userAgent.isVersionOrHigher(8) ? 0 : contentEl.offsetTop; // IE (tested on IE8) sometime does not scroll enough by about // 1px. So we add 1px to the scroll amount. This still looks ok in // other browser except for the most degenerate case (menu height <= // item height). // Scroll down if the highlighted item is below the bottom edge. var diff = (el.offsetTop + el.offsetHeight - contentTop) - (contentEl.clientHeight + contentEl.scrollTop) + 1; contentEl.scrollTop += Math.max(diff, 0); // Scroll up if the highlighted item is above the top edge. diff = contentEl.scrollTop - (el.offsetTop - contentTop) + 1; contentEl.scrollTop -= Math.max(diff, 0); } }; /** * Handles clicks on the filter label. Focuses the input element. * @param {goog.events.BrowserEvent} e A browser event. * @private */ goog.ui.FilteredMenu.prototype.onFilterLabelClick_ = function(e) { this.filterInput_.focus(); }; /** @override */ goog.ui.FilteredMenu.prototype.getContentElement = function() { return this.contentElement_ || this.getElement(); }; /** * Returns the filter input element. * @return {Element} Input element. */ goog.ui.FilteredMenu.prototype.getFilterInputElement = function() { return this.filterInput_ || null; }; /** @override */ goog.ui.FilteredMenu.prototype.decorateInternal = function(element) { this.setElementInternal(element); // Decorate the menu content. this.decorateContent(element); // Locate internally managed elements. var el = this.getDomHelper().getElementsByTagNameAndClass( goog.dom.TagName.DIV, goog.getCssName(this.getRenderer().getCssClass(), 'filter'), element)[0]; this.labelEl_ = goog.dom.getFirstElementChild(el); this.filterInput_ = goog.dom.getNextElementSibling(this.labelEl_); this.contentElement_ = goog.dom.getNextElementSibling(el); // Decorate additional menu items (like 'apply'). this.getRenderer().decorateChildren( this, /** @type {!Element} */ (el.parentNode), this.contentElement_); this.initFilterInput_(); };