// 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 Utilities for adding, removing and setting ARIA roles and
 * states as defined by W3C ARIA standard: http://www.w3.org/TR/wai-aria/
 * All modern browsers have some form of ARIA support, so no browser checks are
 * performed when adding ARIA to components.
 *
 */

goog.provide('goog.a11y.aria');

goog.require('goog.a11y.aria.Role');
goog.require('goog.a11y.aria.State');
goog.require('goog.a11y.aria.datatables');
goog.require('goog.array');
goog.require('goog.asserts');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.object');
goog.require('goog.string');


/**
 * ARIA states/properties prefix.
 * @private
 */
goog.a11y.aria.ARIA_PREFIX_ = 'aria-';


/**
 * ARIA role attribute.
 * @private
 */
goog.a11y.aria.ROLE_ATTRIBUTE_ = 'role';


/**
 * A list of tag names for which we don't need to set ARIA role and states
 * because they have well supported semantics for screen readers or because
 * they don't contain content to be made accessible.
 * @private
 */
goog.a11y.aria.TAGS_WITH_ASSUMED_ROLES_ = goog.object.createSet([
  goog.dom.TagName.A, goog.dom.TagName.AREA, goog.dom.TagName.BUTTON,
  goog.dom.TagName.HEAD, goog.dom.TagName.INPUT, goog.dom.TagName.LINK,
  goog.dom.TagName.MENU, goog.dom.TagName.META, goog.dom.TagName.OPTGROUP,
  goog.dom.TagName.OPTION, goog.dom.TagName.PROGRESS, goog.dom.TagName.STYLE,
  goog.dom.TagName.SELECT, goog.dom.TagName.SOURCE, goog.dom.TagName.TEXTAREA,
  goog.dom.TagName.TITLE, goog.dom.TagName.TRACK
]);


/**
 * A list of roles which are considered container roles.
 * Container roles are ARIA roles which use the aria-activedescendant property
 * to manage their active descendants or children. See
 * {@link http://www.w3.org/TR/wai-aria/states_and_properties
 * #aria-activedescendant} for more information.
 * @private @const {!Array<goog.a11y.aria.Role>}
 */
goog.a11y.aria.CONTAINER_ROLES_ = [
  goog.a11y.aria.Role.COMBOBOX, goog.a11y.aria.Role.GRID,
  goog.a11y.aria.Role.GROUP, goog.a11y.aria.Role.LISTBOX,
  goog.a11y.aria.Role.MENU, goog.a11y.aria.Role.MENUBAR,
  goog.a11y.aria.Role.RADIOGROUP, goog.a11y.aria.Role.ROW,
  goog.a11y.aria.Role.ROWGROUP, goog.a11y.aria.Role.TAB_LIST,
  goog.a11y.aria.Role.TEXTBOX, goog.a11y.aria.Role.TOOLBAR,
  goog.a11y.aria.Role.TREE, goog.a11y.aria.Role.TREEGRID
];


/**
 * Sets the role of an element. If the roleName is
 * empty string or null, the role for the element is removed.
 * We encourage clients to call the goog.a11y.aria.removeRole
 * method instead of setting null and empty string values.
 * Special handling for this case is added to ensure
 * backword compatibility with existing code.
 *
 * @param {!Element} element DOM node to set role of.
 * @param {!goog.a11y.aria.Role|string} roleName role name(s).
 */
goog.a11y.aria.setRole = function(element, roleName) {
  if (!roleName) {
    // Setting the ARIA role to empty string is not allowed
    // by the ARIA standard.
    goog.a11y.aria.removeRole(element);
  } else {
    if (goog.asserts.ENABLE_ASSERTS) {
      goog.asserts.assert(
          goog.object.containsValue(goog.a11y.aria.Role, roleName),
          'No such ARIA role ' + roleName);
    }
    element.setAttribute(goog.a11y.aria.ROLE_ATTRIBUTE_, roleName);
  }
};


/**
 * Gets role of an element.
 * @param {!Element} element DOM element to get role of.
 * @return {goog.a11y.aria.Role} ARIA Role name.
 */
goog.a11y.aria.getRole = function(element) {
  var role = element.getAttribute(goog.a11y.aria.ROLE_ATTRIBUTE_);
  return /** @type {goog.a11y.aria.Role} */ (role) || null;
};


/**
 * Removes role of an element.
 * @param {!Element} element DOM element to remove the role from.
 */
goog.a11y.aria.removeRole = function(element) {
  element.removeAttribute(goog.a11y.aria.ROLE_ATTRIBUTE_);
};


/**
 * Sets the state or property of an element.
 * @param {!Element} element DOM node where we set state.
 * @param {!(goog.a11y.aria.State|string)} stateName State attribute being set.
 *     Automatically adds prefix 'aria-' to the state name if the attribute is
 *     not an extra attribute.
 * @param {string|boolean|number|!Array<string>} value Value
 * for the state attribute.
 */
goog.a11y.aria.setState = function(element, stateName, value) {
  if (goog.isArray(value)) {
    value = value.join(' ');
  }
  var attrStateName = goog.a11y.aria.getAriaAttributeName_(stateName);
  if (value === '' || value == undefined) {
    var defaultValueMap = goog.a11y.aria.datatables.getDefaultValuesMap();
    // Work around for browsers that don't properly support ARIA.
    // According to the ARIA W3C standard, user agents should allow
    // setting empty value which results in setting the default value
    // for the ARIA state if such exists. The exact text from the ARIA W3C
    // standard (http://www.w3.org/TR/wai-aria/states_and_properties):
    // "When a value is indicated as the default, the user agent
    // MUST follow the behavior prescribed by this value when the state or
    // property is empty or undefined."
    // The defaultValueMap contains the default values for the ARIA states
    // and has as a key the goog.a11y.aria.State constant for the state.
    if (stateName in defaultValueMap) {
      element.setAttribute(attrStateName, defaultValueMap[stateName]);
    } else {
      element.removeAttribute(attrStateName);
    }
  } else {
    element.setAttribute(attrStateName, value);
  }
};


/**
 * Toggles the ARIA attribute of an element.
 * Meant for attributes with a true/false value, but works with any attribute.
 * If the attribute does not have a true/false value, the following rules apply:
 * A not empty attribute will be removed.
 * An empty attribute will be set to true.
 * @param {!Element} el DOM node for which to set attribute.
 * @param {!(goog.a11y.aria.State|string)} attr ARIA attribute being set.
 *     Automatically adds prefix 'aria-' to the attribute name if the attribute
 *     is not an extra attribute.
 */
goog.a11y.aria.toggleState = function(el, attr) {
  var val = goog.a11y.aria.getState(el, attr);
  if (!goog.string.isEmptyOrWhitespace(goog.string.makeSafe(val)) &&
      !(val == 'true' || val == 'false')) {
    goog.a11y.aria.removeState(el, /** @type {!goog.a11y.aria.State} */ (attr));
    return;
  }
  goog.a11y.aria.setState(el, attr, val == 'true' ? 'false' : 'true');
};


/**
 * Remove the state or property for the element.
 * @param {!Element} element DOM node where we set state.
 * @param {!goog.a11y.aria.State} stateName State name.
 */
goog.a11y.aria.removeState = function(element, stateName) {
  element.removeAttribute(goog.a11y.aria.getAriaAttributeName_(stateName));
};


/**
 * Gets value of specified state or property.
 * @param {!Element} element DOM node to get state from.
 * @param {!goog.a11y.aria.State|string} stateName State name.
 * @return {string} Value of the state attribute.
 */
goog.a11y.aria.getState = function(element, stateName) {
  // TODO(user): return properly typed value result --
  // boolean, number, string, null. We should be able to chain
  // getState(...) and setState(...) methods.

  var attr =
      /** @type {string|number|boolean} */ (
          element.getAttribute(
              goog.a11y.aria.getAriaAttributeName_(stateName)));
  var isNullOrUndefined = attr == null || attr == undefined;
  return isNullOrUndefined ? '' : String(attr);
};


/**
 * Returns the activedescendant element for the input element by
 * using the activedescendant ARIA property of the given element.
 * @param {!Element} element DOM node to get activedescendant
 *     element for.
 * @return {?Element} DOM node of the activedescendant, if found.
 */
goog.a11y.aria.getActiveDescendant = function(element) {
  var id =
      goog.a11y.aria.getState(element, goog.a11y.aria.State.ACTIVEDESCENDANT);
  return goog.dom.getOwnerDocument(element).getElementById(id);
};


/**
 * Sets the activedescendant ARIA property value for an element.
 * If the activeElement is not null, it should have an id set.
 * @param {!Element} element DOM node to set activedescendant ARIA property to.
 * @param {?Element} activeElement DOM node being set as activedescendant.
 */
goog.a11y.aria.setActiveDescendant = function(element, activeElement) {
  var id = '';
  if (activeElement) {
    id = activeElement.id;
    goog.asserts.assert(id, 'The active element should have an id.');
  }

  goog.a11y.aria.setState(element, goog.a11y.aria.State.ACTIVEDESCENDANT, id);
};


/**
 * Gets the label of the given element.
 * @param {!Element} element DOM node to get label from.
 * @return {string} label The label.
 */
goog.a11y.aria.getLabel = function(element) {
  return goog.a11y.aria.getState(element, goog.a11y.aria.State.LABEL);
};


/**
 * Sets the label of the given element.
 * @param {!Element} element DOM node to set label to.
 * @param {string} label The label to set.
 */
goog.a11y.aria.setLabel = function(element, label) {
  goog.a11y.aria.setState(element, goog.a11y.aria.State.LABEL, label);
};


/**
 * Asserts that the element has a role set if it's not an HTML element whose
 * semantics is well supported by most screen readers.
 * Only to be used internally by the ARIA library in goog.a11y.aria.*.
 * @param {!Element} element The element to assert an ARIA role set.
 * @param {!IArrayLike<string>} allowedRoles The child roles of
 * the roles.
 */
goog.a11y.aria.assertRoleIsSetInternalUtil = function(element, allowedRoles) {
  if (goog.a11y.aria.TAGS_WITH_ASSUMED_ROLES_[element.tagName]) {
    return;
  }
  var elementRole = /** @type {string}*/ (goog.a11y.aria.getRole(element));
  goog.asserts.assert(
      elementRole != null, 'The element ARIA role cannot be null.');

  goog.asserts.assert(
      goog.array.contains(allowedRoles, elementRole),
      'Non existing or incorrect role set for element.' +
          'The role set is "' + elementRole + '". The role should be any of "' +
          allowedRoles + '". Check the ARIA specification for more details ' +
          'http://www.w3.org/TR/wai-aria/roles.');
};


/**
 * Gets the boolean value of an ARIA state/property.
 * @param {!Element} element The element to get the ARIA state for.
 * @param {!goog.a11y.aria.State|string} stateName the ARIA state name.
 * @return {?boolean} Boolean value for the ARIA state value or null if
 *     the state value is not 'true', not 'false', or not set.
 */
goog.a11y.aria.getStateBoolean = function(element, stateName) {
  var attr =
      /** @type {string|boolean} */ (
          element.getAttribute(
              goog.a11y.aria.getAriaAttributeName_(stateName)));
  goog.asserts.assert(
      goog.isBoolean(attr) || attr == null || attr == 'true' ||
      attr == 'false');
  if (attr == null) {
    return attr;
  }
  return goog.isBoolean(attr) ? attr : attr == 'true';
};


/**
 * Gets the number value of an ARIA state/property.
 * @param {!Element} element The element to get the ARIA state for.
 * @param {!goog.a11y.aria.State|string} stateName the ARIA state name.
 * @return {?number} Number value for the ARIA state value or null if
 *     the state value is not a number or not set.
 */
goog.a11y.aria.getStateNumber = function(element, stateName) {
  var attr =
      /** @type {string|number} */ (
          element.getAttribute(
              goog.a11y.aria.getAriaAttributeName_(stateName)));
  goog.asserts.assert(
      (attr == null || !isNaN(Number(attr))) && !goog.isBoolean(attr));
  return attr == null ? null : Number(attr);
};


/**
 * Gets the string value of an ARIA state/property.
 * @param {!Element} element The element to get the ARIA state for.
 * @param {!goog.a11y.aria.State|string} stateName the ARIA state name.
 * @return {?string} String value for the ARIA state value or null if
 *     the state value is empty string or not set.
 */
goog.a11y.aria.getStateString = function(element, stateName) {
  var attr =
      element.getAttribute(goog.a11y.aria.getAriaAttributeName_(stateName));
  goog.asserts.assert(
      (attr == null || goog.isString(attr)) &&
      (attr == '' || isNaN(Number(attr))) && attr != 'true' && attr != 'false');
  return (attr == null || attr == '') ? null : attr;
};


/**
 * Gets array of strings value of the specified state or
 * property for the element.
 * Only to be used internally by the ARIA library in goog.a11y.aria.*.
 * @param {!Element} element DOM node to get state from.
 * @param {!goog.a11y.aria.State} stateName State name.
 * @return {!IArrayLike<string>} string Array
 *     value of the state attribute.
 */
goog.a11y.aria.getStringArrayStateInternalUtil = function(element, stateName) {
  var attrValue =
      element.getAttribute(goog.a11y.aria.getAriaAttributeName_(stateName));
  return goog.a11y.aria.splitStringOnWhitespace_(attrValue);
};


/**
 * Returns true if element has an ARIA state/property, false otherwise.
 * @param {!Element} element The element to get the ARIA state for.
 * @param {!goog.a11y.aria.State|string} stateName the ARIA state name.
 * @return {boolean}
 */
goog.a11y.aria.hasState = function(element, stateName) {
  return element.hasAttribute(goog.a11y.aria.getAriaAttributeName_(stateName));
};


/**
 * Returns whether the element has a container ARIA role.
 * Container roles are ARIA roles that use the aria-activedescendant property
 * to manage their active descendants or children. See
 * {@link http://www.w3.org/TR/wai-aria/states_and_properties
 * #aria-activedescendant} for more information.
 * @param {!Element} element
 * @return {boolean}
 */
goog.a11y.aria.isContainerRole = function(element) {
  var role = goog.a11y.aria.getRole(element);
  return goog.array.contains(goog.a11y.aria.CONTAINER_ROLES_, role);
};


/**
 * Splits the input stringValue on whitespace.
 * @param {string} stringValue The value of the string to split.
 * @return {!IArrayLike<string>} string Array
 *     value as result of the split.
 * @private
 */
goog.a11y.aria.splitStringOnWhitespace_ = function(stringValue) {
  return stringValue ? stringValue.split(/\s+/) : [];
};


/**
 * Adds the 'aria-' prefix to ariaName.
 * @param {string} ariaName ARIA state/property name.
 * @private
 * @return {string} The ARIA attribute name with added 'aria-' prefix.
 * @throws {Error} If no such attribute exists.
 */
goog.a11y.aria.getAriaAttributeName_ = function(ariaName) {
  if (goog.asserts.ENABLE_ASSERTS) {
    goog.asserts.assert(ariaName, 'ARIA attribute cannot be empty.');
    goog.asserts.assert(
        goog.object.containsValue(goog.a11y.aria.State, ariaName),
        'No such ARIA attribute ' + ariaName);
  }
  return goog.a11y.aria.ARIA_PREFIX_ + ariaName;
};
