text: // Provides apos.ui, a singleton that provides user interface features
  // used throughout Apostrophe on the browser side.
  //
  // ## Properties of interest
  //
  // ### apos.ui.shiftActive
  //
  // True whenever the shift key is down.
  
  apos.define('apostrophe-ui', {
  
    afterConstruct: function(self) {
      $(function() {
        self.enablePrefix();
        self.enableShift();
        self.enableClickUrl();
        self.enableStyledFileButtons();
        self.enableActionable();
        self.enhanceDropdowns();
        self.enableAjax();
      });
    },
  
    construct: function(self, options) {
  
      self.options = options;
  
      // Sets apos.ui.shiftActive whenever the shift key is down.
  
      self.enableShift = function() {
        // Status of the shift key. Automatically updated.
        self.shiftActive = false;
        var $body = $('body');
        $body.keydown(function(e) {
          if (e.keyCode === 16) {
            self.shiftActive = true;
            apos.emit('shiftDown', e);
          }
        });
  
        $body.keyup(function(e) {
          if (e.keyCode === 16) {
            self.shiftActive = false;
            apos.emit('shiftUp', e);
          }
        });
      };
  
      // Utility that lets you add the data attribute 'data-apos-actionable' to any element and have
      // its `apos-active` class be toggled on click.
      //
      // Optionally you can give the attr a value which can be used to target another element's active
      // class to be toggled.
  
      self.enableActionable = function() {
        var $body = $('body');
        $body.on('click', '[data-apos-actionable]', function(event) {
          var $el = $(this);
          if ($el.attr('data-apos-actionable')) {
            $el.closest('[' + $el.attr('data-apos-actionable') + ']').toggleClass('apos-active');
          } else {
            $el.toggleClass('apos-active');
          }
        });
      };
  
      // Implement automatic width adjustment for `[data-apos-dropdown]` menus that have a
      // `[data-apos-dropdown-button-label]`.
  
      self.enhanceDropdowns = function() {
        $('body').on('click change', '[data-apos-dropdown]', function(event) {
          event.preventDefault();
          var $this = $(this);
          if ($this.find('[data-apos-dropdown-button-label]').length) {
            // only adjust width for button'y dropdowns
            // not global menu dropdowns
            var $label = $(this).find('[data-apos-dropdown-button-label]');
            if ($label.attr('data-apos-dropdown-button-label') === 'active') {
              $label.css('padding-right', $label.attr('data-apos-original-right-padding'));
              $label.attr('data-apos-dropdown-button-label', '');
            } else {
              $label.attr('data-apos-original-right-padding', $label.css('padding-right'));
              var val = parseInt($label.attr('data-apos-original-right-padding').substring(0, $label.attr('data-apos-original-right-padding').length - 2));
              var extra = ($(this).find('[data-apos-dropdown-items]')[0].getBoundingClientRect().width - $label[0].getBoundingClientRect().width + val );
              $label.css('padding-right', extra);
              $label.attr('data-apos-dropdown-button-label', 'active');
            }
          }
        })
      }
  
      // If the URL ends in: #click-whatever
      //
      // ... Then we locate an element with the attribute data-whatever,
      // and trigger a click event on it.
      //
      // This is useful for resuming an activity after requiring the
      // user to log in.
  
      self.enableClickUrl = function() {
  
        // Waiting long enough for both the click and the autoscroll to work is
        // tricky. We need to yield beyond DOMready so that other code installing click
        // handlers on DOMready has had time to do so. And we need to yield a little
        // extra time or the browser will crush our efforts to set scrollTop based on
        // its own idea of where we are on the page (at least in Chrome). ):
  
        setTimeout(function() {
          var hash = window.location.hash;
          var matches = hash.match(/^\#click\-(.*)$/);
          if (matches) {
            var $element = $('[data-' + matches[1] + ']');
            if ($element.length) {
              // Scroll back to the right neighborhood
              var offset = $element.offset();
              var scrollTop = offset.top - 100;
              $('html, body').scrollTop(scrollTop);
              // Now carry out the action
              $('[data-' + matches[1] + ']').trigger('click');
            }
          }
        }, 200);
      };
  
      // If Apostrophe has a global URL prefix, patch
      // jQuery's AJAX capabilities to prepend that prefix
      // to any non-absolute URL. This assists in avoiding the
      // need for application code to be specifically prefix-aware
      // and allows the prefix to be changed whenever needed.
      // See also [apostrophe-express](../apostrophe-express/index.html)
  
      self.enablePrefix = function(prefix) {
  
        if (prefix) {
          $.ajaxPrefilter(function(options, originalOptions, jqXHR) {
            if (options.url) {
              if (!options.url.match(/^[a-zA-Z]+:/))
              {
                options.url = prefix + options.url;
              }
            }
            return;
          });
        }
      };
  
      // Click the original file upload button if the styled
      // proxy for it is clicked. Allows indirect styling of
      // file buttons
      self.enableStyledFileButtons = function() {
        $('body').on('click', '[data-file-styled]', function() {
          $(this).parent().children('input').click();
        });
      };
  
      self.globalBusyCounter = 0;
  
      // If state is true, the interface changes to
      // indicate Apostrophe is busy loading a modal
      // dialog or other experience that preempts other
      // activities. If state is false, the interface
      // is unlocked. Calls may be nested and the
      // interface will not unlock until all
      // locks are released.
      //
      // See also apos.busy for interactions that
      // only need to indicate that one particular
      // element is busy.
  
      self.globalBusy = function(state) {
        if (state) {
          self.globalBusyCounter++;
          if (self.globalBusyCounter === 1) {
            // Newly busy
            self.globalLock();
          }
        } else {
          self.globalBusyCounter--;
          if (!self.globalBusyCounter) {
            // newly free
            self.globalUnlock();
          }
        }
      };
  
      // Do not call this method yourself. It is called
      // by self.globalBusy to display the global spinner.
      // Freel free to override this method to change the UI.
  
      self.globalLock = function() {
        var $freezer = $('<div class="apos-global-busy"></div>');
        $('body').append($freezer);
        $freezer.focus();  // focus adds a tick before triggering the active transition
        $freezer.addClass('active');
      };
  
      // Do not call this method yourself. It is called
      // by self.globalBusy to hide the global spinner.
      // Freel free to override this method to change the UI.
  
      self.globalUnlock = function() {
        var $freezer = $('.apos-global-busy');
        $freezer.removeClass('active');
        $('body').bind('webkitTransitionEnd otransitionend oTransitionEnd msTransitionEnd transitionend', function() {
          $freezer.remove();
        });
      };
  
      // Simple progress display. Enables a progress display
      // inside the given element. If state is true, the
      // element with a [data-progress] attribute is shown,
      // otherwise the element with a [data-finished] attribute
      // is shown. Neither element is required. Supports
      // nested calls; does not revert to indicating complete
      // until the nesting level is 0.
  
      self.busy = function($el, state) {
        var busy = $el.data('busy') || 0;
        if (state) {
          busy++;
          $el.data('busy', busy);
          if (busy === 1) {
            $el.find('[data-progress]').show();
            $el.find('[data-finished]').hide();
          }
        } else {
          busy--;
          $el.data('busy', busy);
          if (!busy) {
            $el.find('[data-progress]').hide();
            $el.find('[data-finished]').show();
          }
        }
      };
  
      // Redirect correctly to the given location on the
      // Apostrophe site, even if the prefix option is in use
      // (you should provide a non-prefixed path). Note that
      // when prefixes are in use it is especially important
      // to use this method rather than simply setting
      // `window.location.href` yourself.
  
      self.redirect = function(slug) {
        var href = apos.prefix + slug;
        if (href === window.location.href) {
          window.location.reload();
        } else {
          window.location.href = href;
        }
      };
  
      // Enhance a plaintext date field with a pikaday date widget.
  
      self.enhanceDate = function($el, options) {
        if (!options) {
          options = {};
        }
        
        new Pikaday(_.merge({
          field: $el[0],
          format: 'YYYY-MM-DD'
        }, options));
      };
  
      // Converts apostrophe-schemas 24 hour time field strings
      // to local time. The format string depends on the
      // `userTimeFormat` option passed to this module, which
      // defaults to US 12 hour time. It must be understandable
      // by `launder.time`, however that method is very tolerant.
  
      self.formatTime = function(time, options) {
        if(!options) {
          options = {};
        }
  
        return moment(time, 'HH:mm:ss').format(self.options.userTimeFormat);
      };
  
      // Status of the shift key. Dynamically updated.
      self.shiftActive = false;
  
      // Add a callback when a link is clicked to trigger
      // a certain action.
      //
      // Example:
      //
      // self.link($el, 'edit', 'blogPost', fn)
      //
      // When a click occurs on anything inside $el with a
      // data-edit-blog-post attribute, invoke
      // "fn" with the jQuery element clicked and the value
      // of the attribute.
      //
      // self.link('apos-manage', 'blogPost', fn)
      //
      // When a click occurs anywhere on the page
      // on something with a data-manage-blog-post
      // attribute, invoke "fn" with the jquery element
      // clicked and the value of that attribute.
      //
      // Event propagation and the default behavior of
      // the click event are both automatically stopped.
      //
      // The word "object" refers to "the object of the sentence."
      // It is a STRING, not a javascript object.
      //
      // The `object` argument may be null if the language doesn't
      // flow that way, for example 'apos-workflow-export' does not
      // end with a noun, so just pass it as `verb`.
  
      self.link = function(sel, verb, object, callback) {
        if (arguments.length === 3) {
          callback = object;
          object = verb;
          verb = sel;
          sel = 'body';
        }
        var attribute;
        if (object === null) {
          attribute = 'data-' + apos.utils.cssName(verb);
        } else {
          attribute = 'data-' + apos.utils.cssName(verb) + '-' + apos.utils.cssName(object);
        }
        var attributeSel = '[' + attribute + ']';
        $(sel).on('click', attributeSel, function() {
          callback($(this), $(this).attr(attribute));
          return false;
        });
      };
  
      self.enableAjax = function() {
        $('body').on('submit', '[data-apos-ajax-context] form', self.ajaxSubmitHandler);
        $('body').on('click', '[data-apos-ajax-context] a', self.ajaxClickHandler);
        $(window).on('popstate', function(event) {
          self.ajaxPopStateHandler(event);
        });
      };
  
      self.ajaxSubmitHandler = function(event) {
        var $form = $(this);
        var method = $form.attr('method') || 'GET';
        // For consistency with the standard behavior of GET method forms, we must blow out anything else in the query
        // string before we serialize. Combining the "here" macro with an input text field isn't an AJAX problem, it's
        // a preexisting issue. -Tom
        var action = $form.attr('action') || (window.location.href.replace(/\?.*$/, ''));
        if (!self.isAjaxUrl(action)) {
          return true;
        }
        // if an empty "relative" URL doesn't work we can consider using:
        // (window.location.pathname.replace(new RegExp('^' + apos.utils.regExpQuote(apos.prefix)), ''));
        var data = $form.serialize();
        self.ajaxGo(self.ajaxContextOf($form), action, data, { method: method });
        return false;
      };
  
      self.ajaxClickHandler = function(event) {
        var $anchor = $(this);
        // Fun fact: the attribute is relative but the property is absolute, which is useful here
        var href = this.href;
        if (!self.isAjaxUrl(href)) {
          return true;
        }
        if ($anchor.attr('data-apos-no-ajax')) {
          return true;
        }
        var target = $anchor.attr('target');
        if (target) {
          // Let the event proceed normally
          return true;
        }
        self.ajaxGo(self.ajaxContextOf($anchor), href);
        return false;
      };
  
      // Given a jQuery object, return the name of the ajax context containing it
      // (the `data-apos-ajax-context` attribute of its closest ancestor that has one).
  
      self.ajaxContextOf = function($el) {
        return $el.closest('[data-apos-ajax-context]').attr('data-apos-ajax-context');
      };
  
      // Refresh the named data-apos-ajax-context with the content returned by the specified
      // URL. The URL is submitted with the additional query data specified by `data`, if any;
      // it may be a string (the output of serializing a form) or an object for convenience.
      // The URL will be pushed to the browser history as specified unless `options.push`
      // is explicitly `false`.
      //
      // Any text input or textarea fields, and any other elements with a distinct data-apos-ajax-preserve
      // attribute, are NOT refreshed; their ancestors are also not refreshed. This prevents loss of
      // focus when typing which is a very severe cross browser problem otherwise.
      //
      // After the replacement takes place, the `enhance` and `ajax` Apostrophe events
      // are emitted, in that order, with a jQuery object containing the ajax context div
      // as the argument.
      //
      // Note that your event handlers for these should watch out for preserved elements and
      // their ancestors, and not do things twice. You can address this by checking for
      // `data-apos-ajax-preserve` and `data-apos-ajax-preserved` attributes.
  
      self.ajaxGo = function(name, url, data, options) {
        data = data || {};
        var $old = $('[data-apos-ajax-context="' + name + '"]');
  
        // A form serialization will already be a string; other callers might pass
        // an object they want us to handle serializing for them. Either way
        // add an apos-ajax=1 parameter
        if (typeof(data) === 'string') {
          if (data.match(/=/)) {
            data += '&apos-ajax=1';
          } else {
            data += 'apos-ajax=1';
          }
        } else if (typeof(data) === 'object') {
          data['apos-ajax'] = 1;
        }
  
        options = _.extend({
          method: 'GET'
        }, options || {});
  
        apos.emit('beforeAjax', $old);
  
        $.ajax({
          url: url,
          method: options.method,
          // Presence of a special query parameter to keep this from being cached as the
          // content for a full page
          data: data,
          success: function(html) {
            var url = this.url;
            if (options.push !== false) {
              self.ajaxPushHistory(name, url);
            }
            var $new = $('<div></div>');
            $new.html(html);
            self.replaceUnpreserved($old, $new, { text: true });
            // Note that this may enhance preserved elements twice. If this is
            // a concern you can avoid it by checking for `data-apos-ajax-preserved`
            apos.emit('enhance', $old);
            apos.emit('ajax', $old);
          },
          error: function(err) {
            self.ajaxError(err);
          }
        });
  
        return false;
  
      };
  
      self.ajaxPopStateHandler = function(event) {
        // jQuery hasn't normalized this one yet, so we need to go to the original for the state
        event = event.originalEvent;
        if (event.state && event.state.aposAjax) {
          self.ajaxGo(event.state.aposAjax.name, document.location, null, { push: false });
        }
      };
  
      // Push the specified URL to the browser history, associating it with the named data-apos-ajax-context
      // element in the page. Any apos-ajax query parameter is stripped off so that the history shows
      // full-page URLs rather than ajax refresh URLs. You will not need to call this method yourself. It is
      // called for you by `ajaxGo`.
  
      self.ajaxPushHistory = function(name, url) {
        // We don't want the extra query parameter that prevents IE from caching the ajax response as a regular
        // full page response to be in the history
        url = url.replace('&apos-ajax=1', '');
        url = url.replace('?apos-ajax=1', '?');
        url = url.replace(/\?$/, '');
        // Call pushState if the browser supports it, if not our graceful degradation is that you don't get
        // "back" support, pretty reasonable considering how rare IE9 is now
        if (('history' in window) && ('pushState' in window.history)) {
          if (!self.ajaxStatePushed) {
            // If this is our first push, establish a state object for the
            // original URL, so we can click "back" to undo our first AJAX click
            window.history.replaceState({ aposAjax: { url: window.location.href, name: name } }, $('title').text(), window.location.href);
            self.ajaxStatePushed = true;
          }
          window.history.pushState({ 'aposAjax': { url: url, name: name } }, $('title').text(), url);
        }
      };
  
      // Returns true if the given URL is appropriate for an AJAX update when found
      // within `data-apos-ajax-context`. To avoid unexpected results the default behavior
      // is quite conservative: the URL must be the same as the current page, except
      // for the query string and/or hashtag.
  
      self.isAjaxUrl = function(url) {
        // Fun fact: the `href` property of an anchor DOM element is always absolute, so we can
        // use this to resolve URLs
        var $anchor = $('<a></a>');
        $anchor.attr('href', url);
        // Lop off query string and beyond, or hashtag and beyond, whichever comes first
        var absoluteNew = $anchor[0].href.replace(/[\?\#].*$/, '');
        var absoluteOld = window.location.href.replace(/[\?\#].*$/, '');
        // Normalize trailing slashes
        absoluteNew = absoluteNew.replace(/\/$/, '');
        absoluteOld = absoluteOld.replace(/\/$/, '');
  
        return absoluteNew === absoluteOld;
      };
  
      self.ajaxError = function(err) {
        apos.notify('Unfortunately a server error occurred. Please try again later.', { type: 'error', dismiss: true });
      };
  
      // This method is used to AJAX-refresh forms without breaking text input in progress
      // and can also be used to single out other elements for preservation during an
      // ajax replace.
      //
      // The jquery objects $old and $new represent HTML container elements. $old is
      // something currently in the DOM such as a form. $new is newly constituted from an AJAX call.
      // Replace the contents of $new with the contents of $old, except that elements
      // of $old with a data-apos-ajax-preserve attribute are preserved, and of
      // necessity their ancestors are also preserved, because removing an input element
      // from the DOM temporarily is as bad as removing it forever as far as text input in
      // progress is concerned.
      //
      // Each preserved element must have a **unique value** for data-apos-ajax-preserve,
      // and this attribute must be consistent between $old and $new.
      //
      // If options.text is true, all text input and textarea elements are automatically
      // marked to be preserved, provided they have a name attribute.
  
      self.replaceUnpreserved = function($old, $new, options) {
  
        options = options || {};
  
        if (options.text) {
          autoPreserveText();
        }
  
        removeOrphans();
        tagAncestors($old);
        tagAncestors($new);
        cleanout($old);
        restore($old, $new);
  
        function autoPreserveText() {
          var $preserve = $old.find('input[name]:focus,textarea[name]:focus');
          $preserve.each(function() {
            var type = $(this).attr('type');
            // Watch out for input element types that are not actually text
            if (_.includes([ 'checkbox', 'radio', 'submit' ], type)) {
              return;
            }
            var $el = $(this);
            var name = $el.attr('name');
            attr($el, 'preserve', name);
            attr(findName($new, name), 'preserve', name);
          });
        }
  
        // If an element no longer exists in the new markup, then there is nothing to preserve, so don't try
        function removeOrphans() {
          var $alive = find($new, 'preserve');
          var $previous = find($old, 'preserve');
          $previous.each(function() {
            var $el = $(this);
            if (!isVal($alive, 'preserve', attr($el, 'preserve'))) {
              $el.remove();
            }
          });
        }
  
        // Tag ancestors of each preserved element as preserving it, in a way that uniquely tags them for each
        // preserved element, including indicating how many generations removed they are, so that we can find
        // equivalent parent elements between $old and $new
        function tagAncestors($context) {
          // If we don't do this they start doubling up on the second and subsequent refreshes
          find($context, 'preserves').removeAttr('data-apos-ajax-preserves');
          var $preserve = find($context, 'preserve');
          $preserve.each(function() {
            var $el = $(this);
            var level = 0;
            $el.parentsUntil($context).each(function() {
              var $ancestor = $(this);
              addPreserves($ancestor, $el, level);
              level++;
            });
          });
        }
  
        // Scrap all elements of $old that are neither preserved, nor preserving a descendant
        function cleanout($old) {
          $old.contents().each(function() {
            var $node = $(this);
            if (is($node, 'preserves')) {
              cleanout($node);
            } else if (is($node, 'preserve')) {
              // Leave it
            } else {
              $node.remove();
            }
          });
        }
  
        // Copy children of $new to $old except where a preserved element or its ancestor must be respected;
        // flow the new elements around those, after invoking recursively where appropriate
        function restore($old, $new) {
          // We append the next element after this point. If $after is not yet set, we prepend to $new
          // and then set $after to the node just prepended.
          var $after;
          $new.contents().each(function() {
            var $node = $(this);
            var handled = false;
            if (is($node, 'preserve')) {
              var $nodeOld = findVal($old, 'preserve', attr($node, 'preserve'));
              $after = $nodeOld;
              handled = true;
            } else if (is($node, 'preserves')) {
              var $nodeOld = findVal($old, 'preserves', attr($node, 'preserves'));
              if ($nodeOld.length) {
                restore($nodeOld, $node);
                $after = $nodeOld;
                handled = true;
              }
            }
            if (!handled) {
              if ($after) {
                $after.after($node);
              } else {
                $old.prepend($node);
              }
              $after = $node;
            }
          });
        }
  
        // Fetch an array of name:ancestorLevel pairs indicating what this element is preserving
        function getPreserves($el) {
          var val = attr($el, 'preserves');
          if (!val) {
            return [];
          }
          return val.split(',');
        }
  
        // Add a new name:ancestorLevel pair for the element specified by $preserve
        function addPreserves($el, $preserve, level) {
          attr($el, 'preserves', getPreserves($el).concat([ attr($preserve, 'preserve') + ':' + level ]).join(','));
        }
  
        // Find prefixed
        function find($el, key) {
          return $el.find('[data-apos-ajax-' + key + ']');
        }
  
        // Find prefixed with specified value
        function findVal($el, key, val) {
          return $el.find('[data-apos-ajax-' + key + '="' + val + '"]');
        }
  
        // .is() prefixed
        function is($el, key) {
          return $el.is('[data-apos-ajax-' + key + ']');
        }
  
        // is() prefixed with specified value
        function isVal($el, key, val) {
          return $el.is('[data-apos-ajax-' + key + '="' + val + '"]');
        }
  
        // .attr() prefixed, getter and setter
        function attr($el, key, val) {
          if (arguments.length === 2) {
            return $el.attr('data-apos-ajax-' + key);
          }
          $el.attr('data-apos-ajax-' + key, val);
        }
  
        // Find by name attribute, unprefixed, for autoprotecting input fields
        function findName($el, name) {
          return $el.find('[name="' + name + '"]');
        }
  
      };
  
      apos.ui = self;
    }
  });
  