html/index.js

/**
 * Combo HTML
 *
 * It provides enhancements for HTML:
 *
 * - Support `data-confirm="message"` attributes, which shows
 *   a confirmation modal with the given message.
 *
 * - Support `data-method="patch|post|put|delete"` attributes,
 *   which sends the current click as a PATCH/POST/PUT/DELETE
 *   HTTP request. You will need to add `data-to` with the URL
 *   and `data-csrf` with the CSRF token value.
 *
 * - Dispatch a `combo.link.click` event, which provides a mechanism to
 *   customize the default behaviour above.
 *   Stopping propagation for this event will disable `data-confirm`.
 *   Prevent default behaviour for this event will disable `data-method`.
 *
 * ## Setup
 *
 * To use the functionality above, you must load code into your build tool:
 *
 * ```javascript
 * import html from "combo/html";
 *
 * html.init();
 * ```
 *
 * ## Customizing the default behaivour
 *
 * ### Customizing the `data-confirm` behaviour
 *
 * Intercept the `combo.link.click` event before it bubbling up to `window`
 * and do your own custom logic.
 *
 * For example, you could replace the default behavior using `window.confirm`
 * with a custom implementation based on [vex](https://github.com/HubSpot/vex):
 *
 * ```javascript
 * // Compared to window.confirm, the custom dialog does not block JavaScript
 * // execution. Therefore to make this work as expected we store the successful
 * // confirmation as an attribute and re-trigger the click event.
 *
 * // On the second click, the `data-confirm-resolved` attribute is set
 * // and we proceed.
 * const RESOLVED_ATTRIBUTE = "data-confirm-resolved";
 *
 * // listen on document.body, so it's executed before the default of
 * // html.init(), which is listening on the window object.
 * document.body.addEventListener(
 *   "combo.link.click",
 *   function (e) {
 *     // Prevent default implementation
 *     e.stopPropagation();
 *
 *     // Introduce alternative implementation
 *     const message = e.target.getAttribute("data-confirm");
 *     if (!message) {
 *       return;
 *     }
 *
 *     // Confirm is resolved execute the click event
 *     if (e.target?.hasAttribute(RESOLVED_ATTRIBUTE)) {
 *       e.target.removeAttribute(RESOLVED_ATTRIBUTE);
 *       return;
 *     }
 *
 *     // Confirm is needed, preventDefault and show your modal
 *     e.preventDefault();
 *     e.target?.setAttribute(RESOLVED_ATTRIBUTE, "");
 *
 *     vex.dialog.confirm({
 *       message: message,
 *       callback: function (value) {
 *         if (value == true) {
 *           // Customer confirmed, re-trigger the click event.
 *           e.target?.click();
 *         } else {
 *           // Customer canceled
 *           e.target?.removeAttribute(RESOLVED_ATTRIBUTE);
 *         }
 *       },
 *     });
 *   },
 *   false,
 * );
 * ```
 *
 * ### Creating new custom behavior
 *
 * ```javascript
 * window.addEventListener(
 *   "combo.link.click",
 *   function (e) {
 *     // Introduce new behaviour
 *     var message = e.target.getAttribute("data-prompt");
 *     var answer = e.target.getAttribute("data-prompt-answer");
 *     if (message && answer && answer != window.prompt(message)) {
 *       e.preventDefault();
 *     }
 *   },
 *   false,
 * );
 * ```
 */

function init() {
  var PolyfillEvent = eventConstructor()

  function eventConstructor() {
    if (typeof window.CustomEvent === 'function') return window.CustomEvent
    // IE<=9 Support
    function CustomEvent(event, params) {
      params = params || { bubbles: false, cancelable: false, detail: undefined }
      var evt = document.createEvent('CustomEvent')
      evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail)
      return evt
    }
    CustomEvent.prototype = window.Event.prototype
    return CustomEvent
  }

  function buildHiddenInput(name, value) {
    var input = document.createElement('input')
    input.type = 'hidden'
    input.name = name
    input.value = value
    return input
  }

  function handleClick(element, targetModifierKey) {
    var to = element.getAttribute('data-to'),
      method = buildHiddenInput('_method', element.getAttribute('data-method')),
      csrf = buildHiddenInput('_csrf_token', element.getAttribute('data-csrf')),
      form = document.createElement('form'),
      submit = document.createElement('input'),
      target = element.getAttribute('target')

    form.method = element.getAttribute('data-method') === 'get' ? 'get' : 'post'
    form.action = to
    form.style.display = 'none'

    if (target) form.target = target
    else if (targetModifierKey) form.target = '_blank'

    form.appendChild(csrf)
    form.appendChild(method)
    document.body.appendChild(form)

    // Insert a button and click it instead of using `form.submit`
    // because the `submit` function does not emit a `submit` event.
    submit.type = 'submit'
    form.appendChild(submit)
    submit.click()
  }

  window.addEventListener(
    'click',
    function (e) {
      var element = e.target
      if (e.defaultPrevented) return

      while (element && element.getAttribute) {
        var comboLinkEvent = new PolyfillEvent('combo.link.click', {
          bubbles: true,
          cancelable: true,
        })

        if (!element.dispatchEvent(comboLinkEvent)) {
          e.preventDefault()
          e.stopImmediatePropagation()
          return false
        }

        if (element.getAttribute('data-method') && element.getAttribute('data-to')) {
          handleClick(element, e.metaKey || e.shiftKey)
          e.preventDefault()
          return false
        } else {
          element = element.parentNode
        }
      }
    },
    false,
  )

  window.addEventListener(
    'combo.link.click',
    function (e) {
      var message = e.target.getAttribute('data-confirm')
      if (message && !window.confirm(message)) {
        e.preventDefault()
      }
    },
    false,
  )
}

export { init }
export default { init }