zebra.src.js

(function() {

    'use strict';

    // we'll use this to keep track of registered event listeners
    // eslint-disable-next-line no-unused-vars
    const event_listeners = new Map();

    // we'll use this when generating random IDs
    // eslint-disable-next-line no-unused-vars
    let internal_counter = 0;

    // this is the function used internally to create ZebraJS objects using the given arguments
    // at the end of this file we give it a simpler name, like "$", but internally we'll use it like it is

    /**
    *   Creates a ZebraJS object which provides methods meant for simplifying the interaction with the set of
    *   elements matched by the `selector` argument. This is referred to as `wrapping` those elements.
    *
    *   @example
    *
    *   // select an element by ID
    *   const element = $('#foo');
    *
    *   // select element by class name
    *   const elements = $('.foo');
    *
    *   // select elements by using JavaScript
    *   const elements = $(document.querySelectorAll('.foo'));
    *
    *   // use CSS selectors
    *   const elements = $('input[type=text]');
    *
    *   // create elements
    *   const element = $('<div>').addClass('foo').appendTo($('body'));
    *
    *   // SECURITY WARNING: When creating HTML elements from strings, NEVER use untrusted user input directly.
    *   // This could lead to Cross-Site Scripting (XSS) attacks.
    *
    *   // UNSAFE - user input could contain malicious code:
    *   // const userInput = '<img src=x onerror=alert(1)>';
    *   // $(userInput);  // XSS vulnerability!
    *
    *   // SAFE - create elements programmatically and set user data via methods:
    *   // $('<div>').text(userInput);  // User input is safely escaped
    *   // $('<img>').attr('src', userInput);  // Safely set attributes
    *
    *   // SAFE - sanitize user input with a library like DOMPurify before creating HTML:
    *   // const sanitized = DOMPurify.sanitize(userInput);
    *   // $(sanitized);
    *
    *   @param  {mixed}     selector        A selector to filter DOM elements from the current document. It can be a
    *                                       query selector, a {@link ZebraJS} object, a DOM element, a
    *                                       {@link https://developer.mozilla.org/en-US/docs/Web/API/NodeList NodeList},
    *                                       and array of DOM elements<br><br>Alternatively, it can be a HTML tag
    *                                       to create.<br>
    *                                       <blockquote>**Never pass unsanitized user input when creating HTML elements,
    *                                       as this can lead to XSS vulnerabilities!**</blockquote>
    *
    *   @param  {mixed}     [parent]        A selector to filter DOM elements from the current document, but only
    *                                       those which have as parent the element(s) indicated by this argument. It
    *                                       can be a query selector, a {@link ZebraJS} object, a DOM element, a
    *                                       {@link https://developer.mozilla.org/en-US/docs/Web/API/NodeList NodeList},
    *                                       and array of DOM elements
    *
    *   @param  {boolean}   [first_only]    Setting this argument will instruct the method to return only the first
    *                                       element from the set of matched elements.
    *
    *   @return {array}     Returns a special array holding the matching elements and having all the methods to help
    *                       you work with those elements.
    *
    *   @author     Stefan Gabos <contact@stefangabos.ro>
    *   @version    2.0.0 (last revision December 23, 2025)
    *   @copyright  (c) 2016-2025 Stefan Gabos
    *   @license    LGPL-3.0
    *   @alias      ZebraJS
    *   @class
    */
    $ = function(selector, parent, first_only) {

        let elements = [], result;

        // refer to document.body node if it is the case
        if (typeof selector === 'string' && selector.toLowerCase() === 'body') selector = document.body;

        // if selector is given as a string
        if (typeof selector === 'string')

            // if it seems that we want to *create* an HTML node
            if (selector.startsWith('<') && selector.indexOf('>') > 1 && selector.length > 2) {

                // create a dummy container
                parent = document.createElement('div');

                // set its body to the selector string
                parent.innerHTML = selector;

                // add created node to the elements array
                elements.push(parent.firstChild);

            // if we want to select elements
            } else {

                // if parent is not given, consider "document" to be the parent
                if (!parent) parent = document;

                // if parent is set and is a ZebraJS object, refer to the first DOM element from the set instead
                else if (typeof parent === 'object' && parent.version) parent = parent[0];

                // if parent is set and is a string, refer to the matching DOM element
                else if (typeof parent === 'string') parent = document.querySelector(parent);

                // if the selector is an ID
                // select the matching element and add it to the elements array
                if (selector.match(/^\#[^\s]+$/)) elements.push(parent.querySelector(selector));

                // if the "first_only" argument is set
                else if (first_only) {

                    result = _query(selector, parent, 'first');
                    if (result) elements.push(result);

                // if we need all elements
                } else elements = _query(selector, parent);

            }

        // if selector is the Document object, the Window object, a DOM node or a text node
        else if (typeof selector === 'object' && (selector instanceof Document || selector instanceof Window || selector instanceof Element || selector instanceof Text))

            // add it to the elements array
            elements.push(selector);

        // if selector is a NodeList (returned by document.querySelectorAll), add items to the elements array
        else if (selector instanceof NodeList) elements = Array.from(selector);

        // if selector is an array of DOM elements, add them to the elements array
        else if (Array.isArray(selector)) elements = [...selector];

        // if the selector is a ZebraJS object, simply return it
        else if (typeof selector === 'object' && selector.version) return selector;

        // remove undefined values
        elements = elements.filter(value => value !== undefined && value !== null);

        // attach all the ZebraJS methods to the elements array (including plugins, if any)
        Object.assign(elements, $.fn);

        // return the elements "array-on-steroids"
        return elements;

    }

    $.fn = {

        // zebrajs version
        version: '2.0.0'

    };

    /**
     *  Selector engine that handles both standard CSS selectors and jQuery pseudo-selectors.
     *
     *  Supported pseudo-selectors:
     *
     *  - :first - First element in the set
     *  - :last - Last element in the set
     *  - :even - Even-indexed elements (0, 2, 4...)
     *  - :odd - Odd-indexed elements (1, 3, 5...)
     *  - :eq(n) - Element at index n (supports negative indices)
     *  - :gt(n) - Elements after index n
     *  - :lt(n) - Elements before index n
     *  - :has(selector) - Elements containing a descendant matching selector
     *  - :contains(text) - Elements containing the specified text
     *  - :visible - Visible elements
     *  - :hidden - Hidden elements
     *  - :parent - Elements that have children
     *  - :header - All header elements (h1-h6)
     *  - :input - All form input elements
     *  - :text, :checkbox, :radio, :password, :submit, :reset, :button, :file, :image - Input types
     *
     *  @param  {string}    selector            The selector string (CSS or jQuery pseudo-selector)
     *
     *  @param  {Element}   [context]           The element to search within
     *                                          <br><br>
     *                                          Default is `document`
     *
     *  @param  {string}    [mode]              The mode in which to use the function.
     *                                          <br><br>
     *                                          Possible values are:
     *                                          <br><br>
     *                                          - `all` (default)   - the function returns `all` matched elements<br>
     *                                          - `first`           - the function returns the first element from the set<br>
     *                                          - `matches`         - the function works like JavaScript's native .matches() method
     *
     *  @return {array|Element|null|boolean}    Based on mode:
     *                                          <br><br>
     *                                          - `all`             - returns an array of elements (empty array if none found)<br>
     *                                          - `first`           - returns first element or null if none found<br>
     *                                          - `matches`         - returns `TRUE` or `FALSE` based on whether the element matches or not the selector
     *
     *  @private
     */
    const _query = (selector, context = document, mode = 'all') => {

        // helper to warn about invalid selectors
        const err = (sel, msg) => {
            if (typeof console !== 'undefined' && console.warn)
                console.warn(`ZebraJS: Invalid selector "${sel}"${msg ? ', ' + msg : ''}`);
        };

        // default to "all" mode
        if (!mode || (mode !== 'first' && mode !== 'matches')) mode = 'all';

        // pattern to match jQuery pseudo-selectors at the end of the selector
        // this handles the most common case: "div.foo:first", "ul > li:even", etc.
        const pseudo_pattern = /:(first|last|even|odd|eq|gt|lt|has|contains|visible|hidden|parent|header|input|text|checkbox|radio|password|submit|reset|button|file|image)\s*(\([^)]*\))?$/;

        let elements = [], filtered = [];

        try {

            // if non-string or empty selector stop early
            if (typeof selector !== 'string' || selector.trim() === '') return mode === 'first' ? null : [];

            selector = selector.trim();

            let match;

            // if the selector contains pseudo-selectors
            if ((match = selector.match(pseudo_pattern)) !== null) {

                // the pseudo_name is the pseudo-selector (i.e. "first")
                // pseudo_arg will be the argument of the pseudo selector (if any) (
                // i.e the "text" in ":contains(text)"
                // base_selector will be the base CSS selector (if any) that precedes the pseudo-selector
                // i.e. the "div > li" in "div > li:first"
                const [, pseudo_name, pseudo_arg_raw] = match;
                const pseudo_arg = pseudo_arg_raw ? pseudo_arg_raw.slice(1, -1) : null;
                let base_selector = selector.replace(pseudo_pattern, '').trim();

                // if base selector is empty after removing pseudo, use universal selector
                if (!base_selector) base_selector = '*';

                // if we are in "matches" mode
                if (mode === 'matches') {

                    // if we have a positional pseudo-selector
                    if (['first', 'last', 'even', 'odd', 'eq', 'gt', 'lt'].includes(pseudo_name)) {

                        err(`:${pseudo_name}`, 'positional pseudo-selectors not supported in .matches() context');

                        return false;

                    }

                    // test if the context element matches the base selector
                    try {

                        // if
                        if (

                            // base selector is just '*'
                            base_selector === '*' ||

                            // or context matches the base selector
                            context.matches(base_selector)

                        // it matches everything
                        ) elements = [context];

                        // doesn't match, empty array
                        else elements = [];

                    } catch (e) {

                        err(base_selector, e.message);

                        return false;

                    }

                // for the other modes, search for elements in context
                } else

                    try {

                        // get all elements that match the base CSS selector
                        elements = Array.from(context.querySelectorAll(base_selector));

                    // if base selector is invalid, return null to fall back to normal error handling
                    } catch (e) {

                        err(base_selector, e.message);

                        return mode === 'first' ? null : [];

                    }

                // apply the jQuery pseudo-selector filter
                switch (pseudo_name) {

                    case 'first':

                        if (elements.length > 0) filtered = [elements[0]];
                        break;

                    case 'last':

                        if (elements.length > 0) filtered = [elements[elements.length - 1]];
                        break;

                    case 'even':

                        filtered = elements.filter((_el, index) => index % 2 === 0);
                        break;

                    case 'odd':

                        filtered = elements.filter((_el, index) => index % 2 === 1);
                        break;

                    case 'eq':
                    case 'gt':
                    case 'lt':

                        // we treat the argument as a numeric value
                        let index = parseInt(pseudo_arg, 10);

                        // ...but if converting using parseInt yields "NaN" stop now
                        if (isNaN(index)) break;

                        if (pseudo_name === 'eq') {

                            // we're supporting negative indices (count from end)
                            if (index < 0) index = Math.max(0, elements.length + index);

                            // if value is in range, extract the element at position, or empty array otherwise
                            if (index >= 0 && index < elements.length) {
                                filtered = [elements[index]];
                                break;
                            }

                        }

                        // extract the matching elements otherwise, or whatever is already in elements if nothing matches
                        filtered = elements.filter((_el, idx) => (pseudo_name === 'gt' && idx > index) || (pseudo_name === 'lt' && idx < index));

                        break;

                    // elements that contain a descendant matching the selector
                    case 'has':

                        // only if the argument is available
                        if (pseudo_arg !== null && pseudo_arg !== undefined)

                            // filter for a match
                            filtered = elements.filter(el => {
                                try {
                                    return el.querySelectorAll(pseudo_arg).length > 0;
                                } catch (e) {
                                    return false;
                                }
                            });

                        break;

                    // elements containing the specified text (case-sensitive)
                    case 'contains':

                        // only if the argument is available
                        if (pseudo_arg !== null && pseudo_arg !== undefined)

                            // filter for a match
                            filtered = elements.filter(el => el.textContent.includes(pseudo_arg));

                        break;

                    // elements that are visible (not display:none, visibility:hidden, or opacity:0)
                    case 'visible':

                        // filter elements
                        filtered = elements.filter(el => {

                            // if element doesn't take up space
                            if (el.offsetWidth === 0 && el.offsetHeight === 0) return false;

                            // check visibility
                            const style = window.getComputedStyle(el);

                            return style.display !== 'none' &&
                                style.visibility !== 'hidden' &&
                                style.opacity !== '0';

                        });

                        break;

                    // elements that are hidden (opposite of :visible)
                    case 'hidden':

                        // filter elements
                        filtered = elements.filter(el => {

                            // if element takes up space
                            if (el.offsetWidth === 0 && el.offsetHeight === 0) return true;

                            // check visibility
                            const style = window.getComputedStyle(el);

                            return style.display === 'none' ||
                                style.visibility === 'hidden' ||
                                style.opacity === '0';

                        });

                        break;

                    // elements that have children (element or text nodes)
                    case 'parent':

                        filtered = elements.filter(el => el.childNodes.length > 0);

                        break;

                    // all header elements (h1-h6)
                    case 'header':

                        filtered = elements.filter(el => /^H[1-6]$/.test(el.tagName));

                        break;

                    // all form input elements
                    case 'input':

                        filtered = elements.filter(el => /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(el.tagName));

                        break;

                    // input type pseudo-selectors
                    case 'text':
                    case 'checkbox':
                    case 'radio':
                    case 'password':
                    case 'submit':
                    case 'reset':
                    case 'button':
                    case 'file':
                    case 'image':

                        filtered = elements.filter(el => el.tagName === 'INPUT' && el.type === pseudo_name);

                        break;

                    default:

                        filtered = elements;

                }

                // when in "matches" mode, return now
                if (mode === 'matches') return filtered.length > 0;

                // return based on whether the "mode" argument is set to "first" or "all"
                return mode === 'first' ? (filtered.length > 0 ? filtered[0] : null) : filtered;

            }

            // no pseudo-selector found, use native CSS selectors

            // if in "matches" mode
            if (mode === 'matches')

                // test if context element matches the selector
                try {

                    return context.matches(selector);

                } catch (e) {

                    err(selector, e.message);

                    return false;

                }

            // for "all" and "first" modes, search for elements
            return mode === 'first' ? context.querySelector(selector) : Array.from(context.querySelectorAll(selector));

        } catch (e) {

            err(selector, e.message);

            return mode === 'matches' ? false : (mode === 'first' ? null : []);

        }

    };

    /**
     *  Private helper method used by {@link ZebraJS#addClass .addClass()}, {@link ZebraJS#removeClass .removeClass()} and
     *  {@link ZebraJS#toggleClass .toggleClass()} methods.
     *
     *  @param  {string}    action      What to do with the class(es)
     *                                  <br><br>
     *                                  Possible values are `add`, `remove` and `toggle`.
     *
     *  @param  {string}    class_names One or more space-separated class names to be added/removed/toggled for each element
     *                                  in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements (the parents, not the appended elements), for chaining.
     *
     *  @access private
     */
    $.fn._class = function(action, class_names) {

        // split by space and create an array
        class_names = class_names.split(' ');

        // iterate through the set of matched elements
        this.forEach(element => {

            // iterate through the class names to add
            class_names.forEach(class_name => {

                // add or remove class(es)
                element.classList[action === 'add' || (action === 'toggle' && !element.classList.contains(class_name)) ? 'add' : 'remove'](class_name);

            });

        });

        // return the set of matched elements, for chaining
        return this;

    }

    /**
     *  Private helper method used by {@link ZebraJS#clone .clone()} method when called with the `deep_with_data_and_events`
     *  argument set to TRUE. It recursively attaches events and data from an original element's children to its clone
     *  children.
     *
     *  @param  {DOM_element}   element     Element that was cloned
     *
     *  @param  {DOM_element}   clone       Clone of the element
     *
     *  @return {void}
     *
     *  @access private
     */
    $.fn._clone_data_and_events = function(element, clone) {

        // get the original element's and the clone's children
        const elements = Array.from(element.children),
            clones = Array.from(clone.children),
            $this = this;

        // if the original element's has any children
        if (elements && elements.length)

            // iterate over the original element's children
            elements.forEach((element, index) => {

                // iterate over all the existing event listeners
                event_listeners.forEach((listeners, event_type) => {

                    // iterate over the events of current type
                    listeners.forEach(properties => {

                        // if this is an event attached to element we've just cloned
                        if (properties[0] === element) {

                            // also add the event to the clone element
                            $(clones[index]).on(event_type + (properties[2] ? '.' + properties[2] : ''), properties[1]);

                            // if original element has some data attached to it
                            if (element.zjs && element.zjs.data) {

                                // clone it
                                clones[index].zjs = {};
                                clones[index].zjs.data = element.zjs.data;

                            }

                        }

                    });

                });

                // recursively attach events to children's children
                $this._clone_data_and_events(element, clones[index]);

            });

    }

    /**
     *  Private helper method used by {@link ZebraJS#append .append()}, {@link ZebraJS#appendTo .appendTo()},
     *  {@link ZebraJS#after .after()}, {@link ZebraJS#insertAfter .insertAfter()}, {@link ZebraJS#before .before()},
     *  {@link ZebraJS#insertBefore .insertBefore()}, {@link ZebraJS#prepend .prepend()}, {@link ZebraJS#prependTo .prependTo()}
     *  and {@link ZebraJS#wrap .wrap()} methods.
     *
     *  @param  {mixed}     content     Depending on the caller method this is the DOM element, text node, HTML string, or
     *                                  {@link ZebraJS} object to insert in the DOM.
     *
     *  @param  {string}    where       Indicated where the content should be inserted, relative to the set of matched elements.
     *                                  <br><br>
     *                                  Possible values are `after`, `append`, `before`, `prepend` and `wrap`.
     *
     *  @return {ZebraJS}   Returns the set of matched elements (the parents, not the appended elements), for chaining.
     *
     *  @access private
     */
    $.fn._dom_insert = function(content, where) {

        const $this = this;

        // make a ZebraJS object out of whatever given as content
        content = $(content);

        // iterate through the set of matched elements
        this.forEach((element, element_index) => {

            // since content is an array of DOM elements or text nodes
            // iterate over the array
            content.forEach(item => {

                // where the content needs to be moved in the DOM
                switch (where) {

                    // insert a clone after each target except for the last one after which we insert the original content
                    case 'after':
                    case 'replace':
                    case 'wrap': element.parentNode.insertBefore(element_index < $this.length - 1 ? item.cloneNode(true) : item, element.nextSibling); break;

                    // add a clone to each parent except for the last one where we add the original content
                    case 'append': element.appendChild(element_index < $this.length - 1 ? item.cloneNode(true) : item); break;

                    // insert a clone before each target except for the last one before which we insert the original content
                    case 'before': element.parentNode.insertBefore(element_index < $this.length - 1 ? item.cloneNode(true) : item, element); break;

                    // prepend a clone to each parent except for the last one where we add the original content
                    case 'prepend': element.insertBefore(element_index < $this.length - 1 ? item.cloneNode(true) : item, element.firstChild); break;

                }

                // if we're wrapping the element
                if (where === 'wrap' || where === 'replace') {

                    // remove the original element
                    element.parentNode.removeChild(element);

                    // for the "wrap" method, insert the removed element back into the container
                    if (where === 'wrap') item.appendChild(element);

                }

            });

        });

        // return the newly inserted element(s), for chaining
        return content;

    }

    /**
     *  Private helper method used by {@link ZebraJS#children .children()}, {@link ZebraJS#siblings .siblings()},
     *  {@link ZebraJS#next .next()} and {@link ZebraJS#prev .prev()} methods.
     *
     *  @param  {string}    action      Specified what type of elements to look for
     *                                  <br><br>
     *                                  Possible values are `children` and `siblings`.
     *
     *  @param  {string}    selector    If the selector is supplied, the elements will be filtered by testing whether they
     *                                  match it.
     *
     *  @return {ZebraJS}   Returns the found elements, as a ZebraJS object
     *
     *  @access private
     */
    $.fn._dom_search = function(action, selector) {

        let remove_id, root, tmp;
        const result = [];
        const $this = this;

        // iterate through the set of matched elements
        this.forEach(element => {

            remove_id = false;

            // if selector is specified
            if (selector) {

                // if we're looking for children nodes, the root element is the element itself
                if (action === 'children') root = element;

                // otherwise, the root element is the element's parent node
                else root = element.parentNode;

                // if the root element doesn't have an ID,
                if (null === root.getAttribute('id')) {

                    // generate and set a random ID for the element's parent node
                    root.setAttribute('id', $this._random('id'));

                    // set this flag so that we know to remove the randomly generated ID when we're done
                    remove_id = true;

                }

            }

            // if we're looking for siblings
            if (action === 'siblings') {

                // cache parent node to avoid multiple property accesses
                const parent = element.parentNode;

                // get the element's parent's children nodes which, optionally, match a given selector
                // and add them to the results array, skipping the current element
                result.push(...Array.from(selector ? parent.querySelectorAll(`#${parent.id}>${selector}`) : parent.children).filter(child => child !== element));

            // if we're looking for children
            } else if (action === 'children')

                // get the element's children nodes which, optionally, match a given selector
                // and add them to the results array
                result.push(...Array.from(selector ? root.querySelectorAll(`#${root.id}>${selector}`) : element.children));

            // if we're looking next/previous sibling
            else if (action === 'previous' || action === 'next') {

                // get the next/previous sibling
                tmp = element[(action === 'next' ? 'next' : 'previous') + 'ElementSibling'];

                // if there's no selector specified or there is and it matches
                if (!selector || $(tmp).is(selector))

                    // add it to the results array
                    result.push(tmp);

            }

            // if present, remove the randomly generated ID
            // we remove the randomly generated ID from the element
            if (remove_id) root.removeAttribute('id');

        });

        // return the result, as a ZebraJS object
        return $(result);

    }

    /**
     *  Private helper method
     *
     *  @access private
     */
    $.fn._random = function(prefix) {

        // if the internal counter is too large, reset it
        if (internal_counter > Number.MAX_VALUE) internal_counter = 0;

        // return a pseudo-random string by incrementing the internal counter
        return `${prefix}_${internal_counter++}`;

    }

    /**
     *  Private helper method used by traversal/filtering methods to set prevObject for .end() support.
     *
     *  @param  {ZebraJS}   result  The ZebraJS object to set prevObject on
     *
     *  @return {ZebraJS}   Returns the result with prevObject set to this
     *
     *  @access private
     */
    $.fn._add_prev_object = function(result) {

        // store reference to the previous object so .end() can restore it
        result.prevObject = this;

        // return the result
        return result;

    }

    /**
     * CSS properties that accept unitless numeric values.
     *
     * These properties don't need 'px' or other units appended when setting numeric values.
     * Includes standard CSS properties, flexbox, grid, and SVG properties.
     *
     * @private
     * @constant {Array<string>}
     */
    // eslint-disable-next-line no-unused-vars
    const unitless_properties = [

        // Animation
        'animationIterationCount',

        // Border & Image
        'borderImageOutset',
        'borderImageSlice',
        'borderImageWidth',

        // Flexbox (legacy)
        'boxFlex',
        'boxFlexGroup',
        'boxOrdinalGroup',

        // Columns
        'columnCount',
        'columns',

        // Flexbox
        'flex',
        'flexGrow',
        'flexNegative',
        'flexOrder',
        'flexPositive',
        'flexShrink',

        // Font
        'fontWeight',
        'lineClamp',
        'lineHeight',

        // Grid
        'gridArea',
        'gridColumn',
        'gridColumnEnd',
        'gridColumnSpan',
        'gridColumnStart',
        'gridRow',
        'gridRowEnd',
        'gridRowSpan',
        'gridRowStart',

        // Display & Opacity
        'opacity',
        'order',
        'orphans',

        // Miscellaneous
        'tabSize',
        'widows',
        'zIndex',
        'zoom',

        // SVG properties
        'fillOpacity',
        'floodOpacity',
        'stopOpacity',
        'strokeDasharray',
        'strokeDashoffset',
        'strokeMiterlimit',
        'strokeOpacity',
        'strokeWidth'

    ];

    /**
     *  Performs an asynchronous HTTP (Ajax) request.
     *
     *  @example
     *
     *  $.ajax({
     *      url: 'http://mydomain.com/index.html',
     *      method: 'GET',
     *      data: {
     *          foo: 'baz',
     *          bar: 'bax'
     *      },
     *      error: () => {
     *          alert('error!');
     *      },
     *      success: () => {
     *          alert('success!');
     *      }
     *  });
     *
     *  @param  {string}    [url]       The URL to which the request is to be sent.<br>
     *                                  You may skip it and set it in the *options* object
     *
     *  @param  {object}    options     A set of key/value pairs that configure the Ajax request.
     *
     *  |  Property         |   Type                |   Description
     *  |-------------------|-----------------------|----------------------------------------------
     *  |   **url**         |   *string*            |   The URL to which the request is to be sent.
     *  |   **async**       |   *boolean*           |   By default, all requests are sent *asynchronously*. If you need synchronous requests, set this option to `false`. Note that synchronous requests may temporarily lock the browser, disabling any actions while the request is active.<br>Default is `true`
     *  |   **beforeSend**  |   *function*          |   A pre-request callback function that can be used to modify the XMLHTTPRequest object before it is sent. Use this to set custom headers, etc. The XMLHTTPRequest object and settings objects are passed as arguments. Returning false from this function will cancel the request.
     *  |   **cache**       |   *boolean*           |   If set to `false`, will force requested pages not to be cached by the browser. Note: Setting cache to `false` will only work correctly with `HEAD` and `GET` requests. It works by appending "_={timestamp}" to the GET parameters. The parameter is not needed for other types of requests.<br>Default is `true`
     *  |   **complete**    |   *function*          |   A function to be called when the request finishes (after `success` and `error` callbacks are executed). The function gets passed two arguments: The XMLHTTPRequest object and a string with the status of the request.
     *  |   **data**        |   *string* / *object* |   Data to be sent to the server. It is converted to a query string, if not already a string. It's appended to the url for GET requests. Object must be `key/value` pairs, where `value` can also be an array.
     *  |   **error**       |   *function*          |   A function to be called if the request fails. The function receives two arguments: The XMLHttpRequest object and a string describing the type of error that occurred.
     *  |   **method**      |   *string*            |   The HTTP method to use for the request (e.g. `POST`, `GET`, `PUT`).
     *  |   **success**     |   *function*          |   A function to be called if the request succeeds. The function gets passed two arguments: the data returned from the server and a string describing the status.
     *
     *
     *  @memberof   ZebraJS
     *  @alias      $&period;ajax
     *  @instance
     */
    $.ajax = function(url, options) {

        const defaults = {
            async: true,
            beforeSend: null,
            cache: true,
            complete: null,
            data: null,
            error: null,
            method: 'get',
            success: null
        };

        let httpRequest;

        // this callback functions is called as the AJAX call progresses
        const callback = function() {

            // get the request's status
            switch (httpRequest.readyState) {

                // if the request is ready to be made
                case 1:

                    // if we have a callback function ready to handle this event, call it now
                    if (typeof options.beforeSend === 'function') options.beforeSend.call(null, httpRequest, options);

                    break;

                // if the request completed
                case 4:

                    // HTTP success status codes are in the 2xx range (200-299)
                    // also treat "304 Not Modified" as success (cached content is valid)
                    const is_success = (httpRequest.status >= 200 && httpRequest.status < 300) || httpRequest.status === 304;

                    // if the request was successful and we have a callback function ready to handle this situation
                    if (is_success && typeof options.success === 'function')

                        // call that function now
                        options.success.call(null, httpRequest.responseText, httpRequest.status);

                    // if the request was unsuccessful and we have a callback function ready to handle this situation
                    else if (!is_success && typeof options.error === 'function')

                        // call that function now
                        options.error.call(null, httpRequest.status, httpRequest.responseText);

                    // if we have a callback function ready to handle the fact that the request completed (regardless if
                    // it was successful or not)
                    if (typeof options.complete === 'function')

                        // call that function now
                        options.complete.call(null, httpRequest, httpRequest.status);

                    break;

            }

        };

        // helper function to recursively serialize objects and arrays
        const serialize = function(obj, prefix) {
            const str = [];

            for (const k in obj)

                if (obj.hasOwnProperty(k)) {

                    const v = obj[k];

                    // build the key - use prefix if available (for nested objects/arrays)
                    const key_name = prefix ? prefix + '[' + k + ']' : k;

                    // if value is an object or array, serialize it recursively
                    if (v !== null && typeof v === 'object' && !v.nodeType) str.push(serialize(v, key_name));

                    // otherwise, encode the key-value pair
                    else str.push(encodeURIComponent(key_name) + '=' + encodeURIComponent(v));

                }

            return str.join('&');
        };

        // if method is called with a single argument
        if (!options) {

            // then "options" is actually the first argument
            options = url;

            // and the "url" is taken from the "options" object
            url = options.url;

        }

        // extend the default options with the ones provided by the user
        options = $.extend(defaults, options);

        // the method of the request needs to be uppercase
        options.method = options.method.toUpperCase();

        // if data is provided and is an object
        if (options.data && typeof options.data === 'object')

            // serialize the data object (handles nested objects and arrays)
            options.data = serialize(options.data);

        // if we don't want to cache requests, append a query string to the existing ones
        if (!options.cache) options.data = (options.data || '') + (options.data ? '&' : '') + '_=' + (+new Date());

        // if the XMLHttpRequest object is available
        if (window.XMLHttpRequest) {

            // instantiate the XMLHttpRequest object
            httpRequest = new XMLHttpRequest();

            // this will be called as the call progresses
            httpRequest.onreadystatechange = callback;

            // this makes the call...
            httpRequest.open(options.method, url + (options.method === 'GET' && options.data ? '?' + options.data : ''), options.async);

            // set the request header
            httpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');

            // with any additional parameters, if provided
            httpRequest.send(options.data);

        }

    }

    /**
     *  Iterates over an array or an object, executing a callback function for each item.
     *
     *  For iterating over a set of matched elements, see the {@link ZebraJS#each each()} method.
     *
     *  @param  {function}  callback    The function to execute for each item in the set. The callback function receives two
     *                                  arguments: the item's position in the set, called `index` (0-based), and the item.
     *                                     The `this` keyword inside the callback function refers to the item.
     *                                  <br><br>
     *                                  *Returning `FALSE` from the callback function breaks the loop!*
     *
     *  > **This method is here only for compatibility purposes and you shouldn't use it - you should use instead JavaScript's
     *  native {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach forEach}**
     *
     *  @example
     *
     *  $.each([1, 2, 3, 4], function(index, value) {
     *      console.log(index + ': ' + value);
     *  });
     *
     *  const obj = {
     *      prop1:  'value1',
     *      prop2:  'value2'
     *  };
     *  $.each(obj, function(index, value) {
     *      console.log(index + ': ' + value);
     *  });
     *
     *  @return {undefined}
     *
     *  @memberof   ZebraJS
     *  @alias      $&period;each
     *  @instance
     */
    $.each = function(array, callback) {

        let key;

        // if argument is an array
        if (Array.isArray(array) || (array.length !== undefined && typeof array !== 'string')) {

            // iterate through the element in the array
            for (key = 0; key < array.length; key++)

                //  apply the callback function
                if (callback.call(array[key], key, array[key]) === false) return;

        // if argument is an object
        } else

            // iterate over the object's properties
            for (key in array)

                //  apply the callback function
                if (callback.call(array[key], key, array[key]) === false) return;

    };


    /**
     *  Merges the properties of two or more objects together into the first object.
     *
     *  @example
     *
     *  // merge the properties of the last 2 objects into the first one
     *  $.extend({}, {foo:  'baz'}, {bar: 'biz'});
     *
     *  // the result
     *  // {foo: 'baz', bar: 'biz'}
     *
     *  @param  {object}    target  An object whose properties will be merged with the properties of the additional objects
     *                              passed as arguments to this method.
     *
     *  @return {object}    Returns an object with the properties of the object given as first argument merged with the
     *                      properties of additional objects passed as arguments to this method.
     *
     *  @memberof   ZebraJS
     *  @alias      $&period;extend
     *  @instance
     */
    $.extend = function(target) {

        let i, property, result;

        // if the "assign" method is available, use it
        if (Object.assign) return Object.assign.apply(null, [target].concat(Array.from(arguments).slice(1)));

        // if the "assign" method is not available

        // if converting the target argument to an object fails, throw an error
        try { result = Object(target); } catch (e) { throw new TypeError('Cannot convert undefined or null to object'); }

        // iterate over the method's arguments
        for (i = 1; i < arguments.length; i++)

            // if argument is an object
            if (typeof arguments[i] === 'object')

                // iterate over the object's properties
                for (property in arguments[i])

                    // avoid bugs when hasOwnProperty is shadowed
                    if (Object.prototype.hasOwnProperty.call(arguments[i], property))

                        // add property to the result
                        result[property] = arguments[i][property];

        // return the new object
        return result;

    }

    /**
     *  Search for a given value within an array and returns the first index where the value is found, or `-1` if the value
     *  is not found.
     *
     *  This method returns `-1` when it doesn't find a match. If the searched value is in the first position in the array
     *  this method returns `0`, if in second `1`, and so on.
     *
     *  > Because in JavaScript `0 == false` (but `0 !== false`), to check for the presence of value within array, you need to
     *  check if it's not equal to (or greater than) `-1`.
     *  <br><br>
     *  > **This method is here only for compatibility purposes and you shouldn't use it - you should use instead JavaScript's
     *  own {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf indexOf}**
     *
     *  @example
     *
     *  // returns 4
     *  $.inArray(5, [1, 2, 3, 4, 5, 6, 7]);
     *
     *  @param  {mixed}     value   The value to search for
     *
     *  @param  {array}     array   The array to search in
     *
     *  @return {integer}   Returns the position of the searched value inside the given array (starting from `0`), or `-1`
     *                      if the value couldn't be found.
     *
     *  @memberof   ZebraJS
     *  @alias      $&period;inArray
     *  @instance
     */
    $.inArray = function(value, array) {

        // return the index of "value" in the "array"
        return array.indexOf(value);

    }

    /**
     *  Determines whether the object given as argument is an array.
     *
     *  > **This method is here only for compatibility purposes and you shouldn't use it - you should use instead JavaScript's
     *  own {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray Array.isArray}**
     *
     *  @example
     *
     *  // returns TRUE
     *  $.isArray([1, 2, 3, 4, 5, 6, 7]);
     *
     *  @param  {mixed}     object  Object to test whether or not it is an array.
     *
     *  @return {bool}              A boolean indicating whether the object is a JavaScript array (not an array-like object
     *                              such as a ZebraJS object).
     *
     *  @memberof   ZebraJS
     *  @alias      $&period;isArray
     *  @instance
     */
    $.isArray = function(object) {

        // returns a boolean indicating whether the object is a JavaScript array
        return Array.isArray(object);

    }

    /**
     *  Adds one or more classes to each element in the set of matched elements.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // add a single class
     *  elements.addClass('foo');
     *
     *  // add multiple classes
     *  elements.addClass('foo baz');
     *
     *  // chaining
     *  elements.addClass('foo baz').css('display', 'none');
     *
     *  @param  {string}    class_names One or more space-separated class names to be added to each element in the
     *                                  set of matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      addClass
     *  @instance
     */
    $.fn.addClass = function(class_names) {

        // add class(es) and return the set of matched elements
        return this._class('add', class_names);

    }

    /**
     *  Inserts content specified by the argument after each element in the set of matched elements.
     *
     *  Both this and the {@link ZebraJS#insertAfter .insertAfter()} method perform the same task, the main difference being
     *  in the placement of the content and the target. With `.after()`, the selector expression preceding the method is the
     *  target after which the content is to be inserted. On the other hand, with `.insertAfter()`, the content precedes the
     *  method and it is the one inserted after the target element.
     *
     *  > Clones of the inserted element will be created after each element in the set of matched elements, except for the last
     *  one. The original item will be inserted after the last element.
     *
     *  > If the content to be inserted is an element existing on the page, clones of the element will be created after each
     *  element in the set of matched elements, except for the last one. The original item will be moved (not cloned) after
     *  the last element.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const target = $('#selector');
     *
     *  // insert a div that we create on the fly
     *  target.after($('<div>').text('hello'));
     *
     *  // same thing as above
     *  target.after($('<div>hello</div>'));
     *
     *  // inserting elements already existing on the page
     *  target.after($('ul'));
     *
     *  // insert a string (which will be transformed in HTML)
     *  target.after('<div>hello</div>');
     *
     *  // chaining
     *  target.append($('div')).addClass('foo');
     *
     *  @param  {mixed}     content     DOM element, text node, HTML string or ZebraJS object to be inserted after each
     *                                  element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      after
     *  @instance
     */
    $.fn.after = function(content) {

        // call the "_dom_insert" private method with these arguments
        return this._dom_insert(content, 'after');

    }

    /**
     *  Perform a custom animation of a set of CSS properties using transitions.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // fade out
     *  elements.animate({
     *      opacity: 0
     *  }, 250, () => {
     *      console.log('Animation is complete!');
     *  });
     *
     *  @param  {object}        properties  An object of CSS properties and values that the animation will move toward.
     *
     *  @param  {number|string} [duration]  A string or a number determining how long, in milliseconds, the animation will run.
     *                                      <br><br>
     *                                      The strings `'fast'` and `'slow'` may also be supplied to indicate durations of
     *                                      `200` and `600` milliseconds, respectively.
     *                                      <br><br>
     *                                      Default is `400`
     *
     *  @param  {string}        [easing]    The easing function to use.
     *                                      <br><br>
     *                                      An easing function specifies the speed at which the animation progresses at
     *                                      different points within the animation.
     *                                      <br><br>
     *                                      Allowed values are:
     *                                      <ul>
     *                                          <li>ease</li>
     *                                          <li>ease-in</li>
     *                                          <li>ease-in-out</li>
     *                                          <li>ease-out</li>
     *                                          <li>linear</li>
     *                                          <li>swing</li>
     *                                          <li>{@link https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function#the_cubic-bezier_class_of_easing_functions cubic-bezier(...)} (see {@link https://easings.net/ this} for some examples)</li>
     *                                      </ul>
     *                                      Default is `swing`
     *
     *  >   This argument may be skipped!
     *
     *  @param  {function}      [callback]  A function to call once the animation is complete, called once per matched element.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      animate
     *  @instance
     */
    $.fn.animate = function(properties, duration, easing, callback) {

        const animation_duration = (duration === 'fast' ? 200 : (duration === 'slow' ? 600 : (typeof duration === 'number' && duration >= 0 ? duration : 400))) / 1000;
        let animation_easing = typeof easing === 'string' ? (['ease', 'ease-in', 'ease-in-out', 'ease-out', 'linear', 'swing'].includes(easing) || easing.match(/cubic\-bezier\(.*?\)/g) ? easing : 'swing') : 'swing';
        const elements_data = [];

        // apply formulas for these easing
        if (animation_easing === 'linear') animation_easing = 'cubic-bezier(0.0, 0.0, 1.0, 1.0)';
        else if (animation_easing === 'swing') animation_easing = 'cubic-bezier(.02, .01, .47, 1)';

        // if the "easing" argument is skipped
        if (typeof easing === 'function') callback = easing;

        // batch all style reads to minimize reflows
        this.forEach(element => {
            elements_data.push({
                element: element,
                styles: window.getComputedStyle(element)
            });
        });

        // batch all style writes
        elements_data.forEach(data => {

            let property, animation_data;
            let cleanup_done = false;
            const final_properties = {};

            // cleanup function that handles both transitionend and timeout scenarios
            const cleanup = function(e) {

                // prevent double execution
                if (cleanup_done) return;
                cleanup_done = true;

                // clear the timeout if it exists
                if (timeout) clearTimeout(timeout);

                // cleanup - remove transition property so future CSS changes don't animate unexpectedly
                data.element.style.transition = '';

                // clean up animation data
                // (this is used in case .stop() is called on the element)
                if ($._data_storage) {

                    animation_data = $._data_storage.get(data.element);

                    // if we have this data set
                    if (animation_data) {

                        // unset these values
                        animation_data.zjs_animating = false;
                        animation_data.zjs_animation_properties = null;
                        animation_data.zjs_animation_cleanup = null;
                        animation_data.zjs_animation_timeout = null;

                    }

                }

                // call user callback if provided
                if (callback) callback.call(data.element, e);

            };

            // initialize WeakMap storage if needed
            if (!$._data_storage) $._data_storage = new WeakMap();

            // get data object for this element
            animation_data = $._data_storage.get(data.element);

            // if no data yet
            if (!animation_data) {

                // initialize and store now
                animation_data = {};
                $._data_storage.set(data.element, animation_data);

            }

            // prepare final properties object with units added
            for (property in properties)
                final_properties[property] = properties[property] + (!isNaN(properties[property]) && !unitless_properties.includes(property) ? 'px' : '');

            // store animation state for .stop() method
            animation_data.zjs_animating = true;
            animation_data.zjs_animation_properties = final_properties;
            animation_data.zjs_animation_cleanup = cleanup;

            // explicitly set the current values of the properties we are about to animate
            for (property in properties)
                data.element.style[property] = data.styles[property];

            // listen for transition end to clean up and call callback
            $(data.element).one('transitionend', cleanup);

            // set a timeout fallback in case transitionend never fires
            // (element removed from DOM, display:none, no actual transition, etc.)
            const timeout = setTimeout(cleanup, (animation_duration * 1000) + 50);

            // store timeout reference for .stop() method
            animation_data.zjs_animation_timeout = timeout;

            // set the transition property
            data.element.style.transition = 'all ' + animation_duration + 's ' + animation_easing;

            // set the final values of the properties we are about to animate
            for (property in final_properties)
                data.element.style[property] = final_properties[property];

        });

        return this;

    }

    /**
     *  Inserts content, specified by the argument, to the end of each element in the set of matched elements.
     *
     *  Both this and the {@link ZebraJS#appendTo .appendTo()} method perform the same task, the main difference being in the
     *  placement of the content and the target. With `.append()`, the selector expression preceding the method is the
     *  container into which the content is to be inserted. On the other hand, with `.appendTo()`, the content precedes the
     *  method, and it is inserted into the target container.
     *
     *  > If there is more than one target element, clones of the inserted element will be created for each target except for
     *  the last one. For the last target, the original item will be inserted.
     *
     *  > If an element selected this way is inserted elsewhere in the DOM, clones of the inserted element will be created for
     *  each target except for the last one. For the last target, the original item will be moved (not cloned).
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const parent = $('#selector');
     *
     *  // append a div that we create on the fly
     *  parent.append($('<div>').text('hello'));
     *
     *  // same thing as above
     *  parent.append($('<div>hello</div>'));
     *
     *  // append one or more elements that already exist on the page
     *  // if "parent" is a single element than the list will be moved inside the parent element
     *  // if "parent" is a collection of elements, clones of the list element will be created for
     *  // each target except for the last one; for the last target, the original list will be moved
     *  parent.append($('ul'));
     *
     *  // append a string (which will be transformed in HTML)
     *  // this is more efficient memory wise
     *  parent.append('<div>hello</div>');
     *
     *  // chaining
     *  parent.append($('div')).addClass('foo');
     *
     *  @param  {mixed}     content     DOM element, text node, HTML string or ZebraJS object to insert at the end of each
     *                                  element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements (the parents, not the appended elements).
     *
     *  @memberof   ZebraJS
     *  @alias      append
     *  @instance
     */
    $.fn.append = function(content) {

        // call the "_dom_insert" private method with these arguments
        return this._dom_insert(content, 'append');

    }

    /**
     *  Inserts every element in the set of matched elements to the end of the parent element(s), specified by the argument.
     *
     *  Both this and the {@link ZebraJS#append .append()} method perform the same task, the main difference being in the
     *  placement of the content and the target. With `.append()`, the selector expression preceding the method is the
     *  container into which the content is to be inserted. On the other hand, with `.appendTo()`, the content precedes the
     *  method, and it is inserted into the target container.
     *
     *  > If there is more than one target element, clones of the inserted element will be created for each target except for
     *  the last one. For the last target, the original item will be inserted.
     *
     *  > If an element selected this way is inserted elsewhere in the DOM, clones of the inserted element will be created for
     *  each target except for the last one. For the last target, the original item will be moved (not cloned).
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const parent = $('#selector');
     *
     *  // append a div that we create on the fly
     *  $('<div>').text('hello').appendTo(parent);
     *
     *  // same thing as above
     *  $('<div>hello</div>').appendTo(parent);
     *
     *  // append one or more elements that already exist on the page
     *  // if "parent" is a single element than the list will be moved inside the parent element
     *  // if "parent" is a collection of elements, clones of the list element will be created for
     *  // each target except for the last one; for the last target, the original list will be moved
     *  $('ul').appendTo(parent);
     *
     *  @param  {ZebraJS}   parent      A ZebraJS object at end of which to insert each element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the ZebraJS object you are appending to.
     *
     *  @memberof   ZebraJS
     *  @alias      appendTo
     *  @instance
     */
    $.fn.appendTo = function(parent) {

        // call the "_dom_insert" private method with these arguments
        return $(parent)._dom_insert(this, 'append');

    }

    /**
     *  Gets the value of an attribute for the first element in the set of matched elements, or sets one or more attributes
     *  for every matched element.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // get the value of an attribute for the first
     *  // element in the set of matched elements
     *  elements.attr('id');
     *
     *  // set a single attribute
     *  elements.attr('title', 'title');
     *
     *  // set multiple attributes
     *  elements.attr({
     *      title: 'title',
     *      href: 'href'
     *  });
     *
     *  // remove an attribute
     *  elements.attr('title', false);
     *
     *  // chaining
     *  elements.attr('title', 'title').removeClass('foo');
     *
     *  @param  {string|object} attribute   If given as a `string` representing an attribute and `value` **is not** set, this
     *                                      method will return that particular attribute's value for the first element in the
     *                                      set of matched elements.
     *                                      <br><br>
     *                                      If given as a `string` representing an attribute and `value` **is** set, this
     *                                      method will set that particular attribute's value for all the elements in the
     *                                      set of matched elements.
     *                                      <br><br>
     *                                      If given as an `object`, this method will set the given attributes to the given
     *                                      values for all the elements in the set of matched elements.
     *
     *  @param  {string}        [value]     The value to be set for the attribute given as argument. *Only used if `attribute`
     *                                      is not an object!*
     *                                      <br><br>
     *                                      Setting it to `false` or `null` will instead **remove** the attribute from the
     *                                      set of matched elements.
     *
     *  @return {ZebraJS|mixed}             When `setting` attributes, this method returns the set of matched elements.
     *                                      When `reading` attributes, this method returns the value of the required attribute.
     *
     *  @memberof   ZebraJS
     *  @alias      attr
     *  @instance
     */
    $.fn.attr = function(attribute, value) {

        // if attribute argument is an object
        if (typeof attribute === 'object')

            // iterate over the set of matched elements
            this.forEach(element => {

                // iterate over the attributes
                for (const i in attribute)

                    // set each attribute
                    element.setAttribute(i, attribute[i]);

            });

        // if attribute argument is a string
        else if (typeof attribute === 'string')

            // if the value argument is provided
            if (undefined !== value)

                // iterate over the set of matched elements
                this.forEach(element => {

                    // if value argument's value is FALSE or NULL
                    if (value === false || value === null)

                        // remove the attribute
                        element.removeAttribute(attribute);

                    // for other values, set the attribute's property
                    else element.setAttribute(attribute, value);

                });

            // if the value argument is not provided
            else

                // return the value of the requested attribute
                // of the first element in the set of matched elements
                // (return "undefined" in case of an empty selection)
                return this[0] ? this[0].getAttribute(attribute) : undefined;

        // if we get this far, return the set of matched elements
        return this;

    }

    /**
     *  Inserts content, specified by the argument, before each element in the set of matched elements.
     *
     *  Both this and the {@link ZebraJS#insertBefore .insertBefore()} method perform the same task, the main difference
     *  being in the placement of the content and the target. With `.before()`, the selector expression preceding the method
     *  is the target before which the content is to be inserted. On the other hand, with `.insertBefore()`, the content
     *  precedes the method, and it is the one inserted before the target element.
     *
     *  > If there is more than one target element, clones of the inserted element will be created before each target except
     *  for the last one. The original item will be inserted before the last target.
     *
     *  > If an element selected this way is inserted elsewhere in the DOM, clones of the inserted element will be created
     *  before each target except for the last one. The original item will be moved (not cloned) before the last target.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const target = $('#selector');
     *
     *  // insert a div that we create on the fly
     *  target.before($('<div>').text('hello'));
     *
     *  // same thing as above
     *  target.before($('<div>hello</div>'));
     *
     *  // use one or more elements that already exist on the page
     *  // if "target" is a single element than the list will be moved before the target element
     *  // if "parent" is a collection of elements, clones of the list element will be created before
     *  // each target, except for the last one; the original list will be moved before the last target
     *  target.before($('ul'));
     *
     *  // insert a string (which will be transformed in HTML)
     *  // this is more efficient memory wise
     *  target.append('<div>hello</div>');
     *
     *  // chaining
     *  target.append($('div')).addClass('foo');
     *
     *  @param  {mixed}     content     DOM element, text node, HTML string, or {@link ZebraJS} object to be inserted before
     *                                  each element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements (the parents, not the inserted elements).
     *
     *  @memberof   ZebraJS
     *  @alias      before
     *  @instance
     */
    $.fn.before = function(content) {

        // call the "_dom_insert" private method with these arguments
        return this._dom_insert(content, 'before');

    }

    /**
     *  Gets the children of each element in the set of matched elements, optionally filtered by a selector.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get all the element's children
     *  const children_all = element.children();
     *
     *  // get all the "div" children of the element
     *  const children_filtered = element.children('div');
     *
     *  // chaining
     *  element.children('div').addClass('foo');
     *
     *  @param  {string}    selector    If the selector is supplied, the elements will be filtered by testing whether they
     *                                  match it.
     *
     *  @return {ZebraJS}   Returns the children of each element in the set of matched elements, as a ZebraJS object.
     *
     *  @memberof   ZebraJS
     *  @alias      children
     *  @instance
     */
    $.fn.children = function(selector) {

        // get the children of each element in the set of matched elements, optionally filtered by a selector
        return this._add_prev_object(this._dom_search('children', selector));

    }

    /**
     *  Creates a deep copy of the set of matched elements.
     *
     *  This method performs a deep copy of the set of matched elements meaning that it copies the matched elements as well
     *  as all of their descendant elements and text nodes.
     *
     *  Normally, any event handlers bound to the original element are not copied to the clone. Setting the `with_data_and_events`
     *  argument to `true` will copy the event handlers and element data bound to the original element.
     *
     *  > This method may lead to duplicate element IDs in a document. Where possible, it is recommended to avoid cloning
     *  elements with this attribute or using class attributes as identifiers instead.
     *
     *  Element data will be shallow-copied when `with_data_and_events` is `true`. This means objects, arrays, and functions
     *  will be shared between the original and clone. To deep copy data, copy each property manually.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // clone element with data and events, including data and events of children
     *  const clones = element.clone(true, true)
     *
     *  // chaining - clone and insert into the body element
     *  element.clone(true, true).appendTo($('body'));
     *
     *  @param  {boolean}   with_data_and_events        Setting this argument to `true` will instruct the method to also copy
     *                                                  event handlers and element data along with the elements.
     *
     *  @param  {boolean}   deep_with_data_and_events   Setting this argument to `true` will instruct the method to also copy
     *                                                  event handlers and data for all children of the cloned element.
     *
     *  @return {ZebraJS}   Returns the cloned elements, as a {@link ZebraJS} object.
     *
     *  @memberof   ZebraJS
     *  @alias      clone
     *  @instance
     */
    $.fn.clone = function(with_data_and_events, deep_with_data_and_events) {

        const result = [];
        const $this = this;

        // iterate over the set of matched elements
        this.forEach(element => {

            // clone the element (together with its children)
            const clone = element.cloneNode(true);

            // add to array
            result.push(clone);

            // if events and data needs to be cloned too
            if (with_data_and_events) {

                // iterate over all the existing event listeners
                event_listeners.forEach((listeners, event_type) => {

                    // iterate over the events of current type
                    listeners.forEach(properties => {

                        // if this is an event attached to element we've just cloned
                        if (with_data_and_events && properties[0] === element)

                            // also add the event to the clone element
                            $(clone).on(event_type + (properties[2] ? '.' + properties[2] : ''), properties[1]);

                    });

                });

                // if WeakMap storage has been initialized
                if ($._data_storage) {

                    // do we have complex objects stored for the element?
                    const element_data = $._data_storage.get(element);

                    // if we do
                    if (element_data) {

                        // create a shallow copy of the data object
                        // objects, arrays, and functions are shared (not deep cloned)
                        const cloned_data = {};

                        for (const key in element_data)
                            cloned_data[key] = element_data[key];

                        // store the cloned data for the cloned element
                        $._data_storage.set(clone, cloned_data);

                    }

                }

            }

            // if event handlers and data for all children of the cloned element should be also copied
            if (deep_with_data_and_events) $this._clone_data_and_events(element, clone);

        });

        // return the clone elements
        return $(result);

    }

    /**
     *  For each element in the set, get the first element that matches the selector by traversing up through its ancestors
     *  in the DOM tree, beginning with the current element.
     *
     *  Given a {@link ZebraJS} object that represents a set of DOM elements, this method searches through the ancestors of
     *  these elements in the DOM tree, beginning with the current element, and constructs a new {@link ZebraJS} object from
     *  the matching elements.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get the first parent that is a div
     *  const closest = element.closest('div');
     *
     *  // chaining
     *  element.closest('div').addClass('foo');
     *
     *  @param  {string}    selector    If the selector is supplied, the parents will be filtered by testing whether they
     *                                  match it.
     *
     *  @return {ZebraJS}   Returns zero or one element for each element in the original set, as a {@link ZebraJS} object
     *
     *  @memberof   ZebraJS
     *  @alias      closest
     *  @instance
     */
    $.fn.closest = function(selector) {

        const result = [];

        // since the checking starts with the element itself, if the element itself matches the selector return now
        if (this[0] && _query(selector, this[0], 'matches')) return this;

        // iterate through the set of matched elements
        this.forEach(element => {

            // unless we got to the root of the DOM, get the element's parent
            while (!((element = element.parentNode) instanceof Document))

                // if selector was specified and element matches it, don't look any further
                if (_query(selector, element, 'matches')) {

                    // if not already in the array, add parent to the results array
                    if (!result.includes(element)) result.push(element);

                    // don't look any further
                    break;

                }

        });

        // return the matched elements, as a ZebraJS object
        return this._add_prev_object($(result));

    }

    /**
     *  Gets the value of a computed style property for the first element in the set of matched elements, or sets one or more
     *  CSS properties for every matched element.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // get the value of a computed style property
     *  // for the first element in the set of matched elements
     *  elements.css('width');
     *
     *  // set a single CSS property
     *  elements.css('position', 'absolute');
     *
     *  // set multiple CSS properties
     *  elements.css({
     *      position: 'absolute',
     *      left: 0,
     *      top: 0
     *  });
     *
     *  // remove a property
     *  elements.attr('position', false);
     *
     *  // chaining
     *  elements.css('position', 'absolute').removeClass('foo');
     *
     *  @param  {string|object} property    If given as a `string` representing a CSS property and `value` **is not** set,
     *                                      this method will return the computed style of that particular property for the
     *                                      first element in the set of matched elements.
     *                                      <br><br>
     *                                      If given as a `string` representing a CSS property and `value` **is** set, this
     *                                      method will set that particular CSS property's value for all the elements in the
     *                                      set of matched elements.
     *                                      <br><br>
     *                                      If given as an `object`, this method will set the given CSS properties to the
     *                                      given values for all the elements in the set of matched elements.
     *
     *  @param  {string}        [value]     The value to be set for the CSS property given as argument. *Only used if `property`
     *                                      is not an object!*
     *                                      <br><br>
     *                                      Setting it to `false` or `null` will instead **remove** the CSS property from the
     *                                      set of matched elements.
     *
     *  @return {ZebraJS|mixed}             When `setting` CSS properties, this method returns the set of matched elements.
     *                                      When `reading` CSS properties, this method returns the value(s) of the required computed style(s).
     *
     *  @memberof   ZebraJS
     *  @alias      css
     *  @instance
     */
    $.fn.css = function(property, value) {

        // if "property" is an object and "value" is not set
        if (typeof property === 'object')

            // iterate through the set of matched elements
            this.forEach(element => {

                // iterate through the "properties" object
                for (const i in property)

                    // set each style property
                    element.style[i] = property[i] +

                        // if value does not have a unit provided and is not one of the unitless properties, add the "px" suffix
                        (parseFloat(property[i]) === property[i] && !unitless_properties.includes(i) ? 'px' : '');

            });

        // if "property" is not an object, and "value" argument is set
        else if (undefined !== value)

            // iterate through the set of matched elements
            this.forEach(element => {

                // if value argument's value is FALSE or NULL
                if (value === false || value === null)

                    // remove the CSS property
                    element.style[property] = '';

                // set the respective style property
                else element.style[property] = value;

            });

        // if "property" is not an object and "value" is not set
        // return the value of the given CSS property, or "undefined" if property is not available
        else {

            // return "undefined" in case of an empty selection
            if (!this[0]) return undefined;

            // get the first element's computed styles
            const computedStyle = window.getComputedStyle(this[0]);

            // return the sought property's value
            return computedStyle[property];

        }

        // if we get this far, return the matched elements
        return this;

    }

    /**
     *  Stores arbitrary data associated with the matched elements, or returns the value at the named data store for the
     *  first element in the set of matched elements.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // set some data
     *  elements.data('foo', 'baz');
     *
     *  // retrieve previously set data
     *  elements.data('foo');
     *
     *  // set an object as data
     *  elements.data('foo', {bar: 'baz', qux: 2});
     *
     *  @param  {string}    name        A string naming the piece of data to set.
     *
     *  @param  {mixed}     value       The value to associate with the data set.
     *
     *  @return {ZebraJS|mixed}         When `setting` data attributes, this method returns the set of matched elements.
     *                                  When `reading` data attributes, this method returns the stored values, or `undefined`
     *                                  if not data found for the requested key.
     *
     *  @memberof   ZebraJS
     *  @alias      data
     *  @instance
     */
    $.fn.data = function(name, value) {

        // WeakMap for storing complex objects (DOM elements, array-like object with DOM elements, functions , etc.)
        // use a shared WeakMap attached to the $ object to persist across calls
        if (!$._data_storage) $._data_storage = new WeakMap();

        // if no name is given, return "undefined"
        if (undefined === name) return undefined;

        // make sure the name follows the Dataset API specs
        // http://www.w3.org/TR/html5/dom.html#dom-dataset
        name = name

            // replace "-" followed by an ascii letter to that letter in uppercase
            .replace(/\-([a-z])/ig, (match, letter) => letter.toUpperCase())

            // remove any left "-"
            .replace(/\-/g, '');

        // if "value" argument is provided
        if (undefined !== value) {

            // iterate through the set of matched elements
            this.forEach(element => {

                // check if value is a complex object that can't be JSON stringified properly
                // (functions, DOM elements, objects with methods)
                if (typeof value === 'function' || (typeof value === 'object' && value !== null && (

                    // DOM element
                    value.nodeType ||

                    // array-like object with at least one DOM element
                    (value.length !== undefined && Array.from(value).some(item => item && item.nodeType))

                ))) {

                    // try to get the existing WeakMap data for the element
                    let element_data = $._data_storage.get(element);

                    // if WeakMap data is not yet initialized
                    if (!element_data) {

                        // initialize it now and set it
                        element_data = {};
                        $._data_storage.set(element, element_data);

                    }

                    // add/update entry with the requested value
                    element_data[name] = value;

                // for non-complex objects
                } else

                    try {

                        // use dataset for simple values
                        element.dataset[name] = typeof value === 'object' ? JSON.stringify(value) : value;

                    // if JSON.stringify fails (e.g., circular reference), fall back to WeakMap
                    } catch (e) {

                        let element_data = $._data_storage.get(element);

                        if (!element_data) {
                            element_data = {};
                            $._data_storage.set(element, element_data);
                        }

                        element_data[name] = value;

                    }

            });

            // return the set of matched elements, for chaining
            return this;

        }

        // make sure we return "undefined" if the next code block doesn't yield a result
        value = undefined;

        // if we are retrieving a data value
        // iterate through the set of matched elements
        this.some(element => {

            // first check if we have any data in the WeakMap associated with the element
            const element_data = $._data_storage.get(element);

            // if we do
            if (element_data && undefined !== element_data[name]) {

                // extract it
                value = element_data[name];

                // don't look further
                return true;

            }

            // then check dataset for simple values
            if (element.dataset && undefined !== element.dataset[name]) {

                // first
                try {

                    // check if the stored value is a JSON object
                    // if it is, convert it back to an object
                    value = JSON.parse(element.dataset[name]);

                // if the stored value is not a JSON object
                } catch (e) {

                    // get value
                    value = element.dataset[name];

                }

                // break out of the loop
                return true;

            }

        });

        // return the found value
        // (or "undefined" if not found)
        return value;

    }

    /**
     *  Removes the set of matched elements from the DOM.
     *
     *  This method is the same as the {@link ZebraJS#remove .remove()} method, except that .detach() keeps all events and
     *  data associated with the removed elements. This method is useful when removed elements are to be reinserted into the
     *  DOM at a later time.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // remove elements from the DOM
     *  const detached = element.detach();
     *
     *  // add them back, together with data and events,
     *  // to the end of the body element
     *  $('body').insert(detached);
     *
     *  @return {ZebraJS}   Returns the removed elements.
     *
     *  @memberof   ZebraJS
     *  @alias      detach
     *  @instance
    */
    $.fn.detach = function() {

        let result = [];

        // iterate over the set of matched elements
        this.forEach(element => {

            // the ZebraJS object
            const $element = $(element);

            // clone the element (deep with data and events and add it to the results array)
            result = result.concat($element.clone(true, true));

            // remove the original element from the DOM
            $element.remove();

        });

        // return the removed elements
        return $(result);

    }

    /**
     *  Iterates over the set of matched elements, executing a callback function for each element in the set.
     *
     *  @param  {function}  callback    The function to execute for each item in the set. The callback function receives two
     *                                  arguments: the element's position in the set, called `index` (0-based), and the DOM
     *                                  element. The `this` keyword inside the callback function refers to the DOM element.
     *                                  <br><br>
     *                                  *Returning `FALSE` from the callback function breaks the loop!*
     *
     *  @example
     *
     *  $('selector').each(function(index) {
     *
     *      // show the element's index in the set
     *      console.log(index);
     *
     *      // remember, inside the callback, the "this" keyword refers to the DOM element
     *      $(this).css('display', 'none');
     *
     *  });
     *
     *  @return {void}
     *
     *  @memberof   ZebraJS
     *  @alias      each
     *  @instance
     */
    $.fn.each = function(callback) {

        // iterate through the set of matched elements
        for (let i = 0; i < this.length; i++)

            //  apply the callback function
            if (callback.call(this[i], i, this[i]) === false) return;

    }

    /**
     *  Ends the most recent filtering operation in the current chain and returns the set of matched elements to its previous state.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // find spans, add a class to them, then go back to the original selection
     *  element.find('span')
     *      .addClass('highlight')
     *      .end()
     *      .addClass('container');
     *
     *  // this is useful for traversing and then returning to the previous set
     *  $('div')
     *      .children('.child')
     *      .addClass('selected')
     *      .end()
     *      .addClass('parent');
     *
     *  @return {ZebraJS}   Returns the previous set of matched elements, or an empty set if there is no previous set.
     *
     *  @memberof   ZebraJS
     *  @alias      end
     *  @instance
     */
    $.fn.end = function() {

        // return the previous object if it exists, otherwise return an empty ZebraJS object
        return this.prevObject || $([]);

    }

    /**
     *  Reduces the set of matched elements to the one at the specified index.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('.selector');
     *
     *  // assuming there are 6 elements in the set of matched elements
     *  // add the "foo" class to the 5th element
     *  elements.eq(4).addClass('foo');
     *
     *  @param  {integer}   index   An integer indicating the 0-based position of the element. If a negative integer is
     *                              given the counting will go backwards, starting from the last element in the set.
     *
     *  @return {ZebraJS}   Returns the element at the specified index, as a ZebraJS object.
     *
     *  @memberof   ZebraJS
     *  @alias      eq
     *  @instance
     */
    $.fn.eq = function(index) {

        // return the element at the specified index
        return this._add_prev_object($(this.get(index)));

    }

    /**
     *  Gets the descendants of each element in the current set of matched elements, filtered by a selector, {@link ZebraJS}
     *  object, or a DOM element.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // find the element's div descendants
     *  const target = element.find('div');
     *
     *  // this is equivalent with the above
     *  const target2 = $('div', element);
     *
     *  // chaining
     *  element.find('div').addClass('foo');
     *
     *  @param  {string}    selector    A selector to filter descendant elements by. It can be a query selector, a
     *                                  {@link ZebraJS} object, or a DOM element.
     *
     *  @return {ZebraJS}   Returns the descendants of each element in the current set of matched elements, filtered by a
     *                      selector, {@link ZebraJS} object, or DOM element, as a {@link ZebraJS} object.
     *
     *  @memberof   ZebraJS
     *  @alias      find
     *  @instance
     */
    $.fn.find = function(selector) {

        let result = [];

        // iterate through the set of matched elements
        this.forEach(element => {

            // if selector is a ZebraJS object
            if (typeof selector === 'object' && selector.version)

                // iterate through the elements in the object
                selector.forEach(wrapped => {

                    // if the elements are the same, add it to the results array
                    if (wrapped.isSameNode(element)) result.push(element);

                });

            // selector is the Document object, a DOM node, the Window object
            else if (typeof selector === 'object' && (selector instanceof Document || selector instanceof Element || selector instanceof Window)) {

                // if the elements are the same, add it to the results array
                if (selector.isSameNode(element)) result.push(element);

            // selector is a string
            // get the descendants of the element that match the selector, and add them to the results array
            } else result.push(..._query(selector, element));

        });

        // when it finds no elements, "querySelector" returns "null"
        // we'll filter those out now
        result = result.filter(entry => entry !== null);

        // return the resulting array as a ZebraJS object
        return this._add_prev_object($(result));

    }

    /**
     *  Constructs a new {@link ZebraJS} object from the first element in the set of matched elements.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // returns the first element from the list of matched elements, as a ZebraJS object
     *  const first = elements.first();
     *
     *  @return {ZebraJS}   Returns the first element from the list of matched elements, as a ZebraJS object
     *
     *  @memberof   ZebraJS
     *  @alias      first
     *  @instance
     */
    $.fn.first = function() {

        // returns the first element from the list of matched elements, as a ZebraJS object
        return this._add_prev_object(this[0] ? $(this[0]) : $());

    }

    /**
     *  Retrieves one of the elements matched by the {@link ZebraJS} object.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // this gets the second DOM element from the list of matched elements
     *  elements.get(1);
     *
     *  @param  {integer}   index   The index (starting from `0`) of the DOM element to return from the list of matched
     *                              elements
     *
     *  @memberof   ZebraJS
     *  @alias      get
     *  @instance
     */
    $.fn.get = function(index) {

        // handle negative indexes
        if (index < 0) index = Math.max(0, this.length + index);

        // return the matching DOM element
        return undefined !== this[index] ? this[index] : undefined;

    }

    /**
     *  Checks whether *any* of the matched elements have the given class.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // check if matched elements have a certain class
     *  const class_exists = elements.hasClass('foo');
     *
     *  // chaining
     *  elements.toggleClass('foo');
     *
     *  @param  {string}    class_name  The name of a class to be checked if it exists on *any* of the elements in the set
     *                                  of matched elements.
     *
     *  @return {boolean}   Returns TRUE if the sought class exists in *any* of the elements in the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      hasClass
     *  @instance
     */
    $.fn.hasClass = function(class_name) {

        // return TRUE if any element has the class
        return this.some(element => element.classList.contains(class_name));

    }

    /**
     *  Returns the content height (without `padding`, `border` and `margin`) of the first element in the set of matched
     *  elements as `float`, or sets the `height` CSS property of every element in the set.
     *
     *  See {@link ZebraJS#outerHeight .outerHeight()} for getting the height including `padding`, `border` and, optionally,
     *  `margin`.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // returns the content height of the first element in the set of matched elements
     *  elements.height();
     *
     *  // sets the "height" CSS property of all elements in the set to 200px
     *  elements.height(200);
     *  elements.height('200');
     *  elements.height('200px');
     *
     *  // sets the "height" CSS property of all elements in the set to 5em
     *  elements.height('5em');
     *
     *  // chaining
     *  elements.height(200).addClass('foo');
     *
     *  @param  {undefined|number|string}   [height]    If not given, this method will return content height (without `padding`,
     *                                                  `border` and `margin`) of the first element in the set of matched
     *                                                  elements.
     *                                                  <br><br>
     *                                                  If given, this method will set the `height` CSS property of all
     *                                                  the elements in the set to that particular value, making sure
     *                                                  to apply the "px" suffix if not otherwise specified.
     *
     *  > For hidden elements the returned value is `0`!
     *
     *  @return {ZebraJS|float}     When **setting** the `height`, this method returns the set of matched elements. Otherwise,
     *                              it returns the content height (without `padding`, `border` and `margin`) of the first
     *                              element in the set of matched elements, as `float`.
     *
     *  @memberof   ZebraJS
     *  @alias      height
     *  @instance
     */
    $.fn.height = function(height) {

        // if "height" is given, set the height of every matched element, making sure to suffix the value with "px"
        // if not otherwise specified
        if (height !== undefined) return this.css('height', height + (parseFloat(height) === height ? 'px' : ''));

        // for the "window"
        if (this[0] === window) return window.innerHeight;

        // for the "document"
        if (this[0] === document)

            // return height
            return Math.max(
                document.body.offsetHeight,
                document.body.scrollHeight,
                document.documentElement.clientHeight,
                document.documentElement.offsetHeight,
                document.documentElement.scrollHeight
            );

        // get the first element's height, top/bottom padding and borders
        const styles = window.getComputedStyle(this[0]);
        const offset_height = this[0].offsetHeight;
        const border_top_width = parseFloat(styles.borderTopWidth);
        const border_bottom_width = parseFloat(styles.borderBottomWidth);
        const padding_top = parseFloat(styles.paddingTop);
        const padding_bottom = parseFloat(styles.paddingBottom);

        // return height
        return offset_height - border_bottom_width - border_top_width - padding_top - padding_bottom;

    }

    /**
     *  Hides an element from the DOM by settings its "display" property to `none`.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('selector');
     *
     *  // hide the element from the DOM
     *  element.hide();
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      hide
     *  @instance
     */
    $.fn.hide = function() {

        // iterate through the set of matched elements
        this.forEach(element => {

            // set the "display" property
            element.style.display = 'none';

        });

        // return the set of matched elements
        return this;

    }

    /**
     *  Gets the HTML content of the first element in the set of matched elements, or set the HTML content of every matched
     *  element.
     *
     *  > There are some {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML#Security_considerations security considerations}
     *  that you should be aware of when using this method.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // set the HTML content for all the matched elements
     *  elements.html('<p>Hello</p>');
     *
     *  // get the HTML content of the first
     *  // element in the set of matched elements
     *  const content = elements.html();
     *
     *  // chaining
     *  elements.html('<p>Hello</p>').addClass('foo');

     *  @param  {string}    [content]   The HTML content to set as the content of all the matched elements. Note that any
     *                                  content that was previously in that element is completely replaced by the new
     *                                  content.
     *
     *  @return {ZebraJS|string}        When the `content` argument is provided, this method returns the set of matched
     *                                  elements. Otherwise it returns the HTML content of the first element in the set of
     *                                  matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      html
     *  @instance
     */
    $.fn.html = function(content) {

        // if content is provided
        if (undefined !== content)

            // iterate through the set of matched elements
            this.forEach(element => {

                // set the HTML content of each element
                element.innerHTML = content;

            });

        // if content is not provided
        // return the content of the first element in the set of matched elements
        else return this[0] ? this[0].innerHTML : undefined;

        // return the set of matched elements
        return this;

    }

    /**
     *  Returns the position of an element among its siblings or within a set of elements.
     *
     *  - If no argument is passed, the method returns an integer indicating the position of the first element within
     *    the {@link ZebraJS} object relative to its sibling elements.
     *
     *  - If a selector is passed as an argument, the return value is an integer indicating the position of the first
     *    element within the {@link ZebraJS} object relative to the elements matched by the selector. If the element is
     *    not found, it returns `-1`.
     *
     *  - If a DOM element or {@link ZebraJS} object is passed, the method returns an integer indicating the position
     *    of that element relative to the elements in the original {@link ZebraJS} object.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('li');
     *
     *  // get the index of the first element among its siblings
     *  let index = elements.index();
     *
     *  // get the index of the first element within a selector
     *  index = elements.index('.active');
     *
     *  // get the index of a specific element within the set
     *  const element = $('li.active');
     *  index = elements.index(element);
     *
     *  @param  {mixed}     [selector]  Can be a selector, a DOM element, or a {@link ZebraJS} object.
     *
     *  @return {number}    Returns the index of the element, or `-1` if not found.
     *
     *  @memberof   ZebraJS
     *  @alias      index
     *  @instance
     */
    $.fn.index = function(selector) {

        // if no argument is provided
        if (undefined === selector) {

            // if we have elements
            if (this.length > 0) {

                // get the first element
                const element = this[0];

                // get all child elements of the parent (or empty array if no parent)
                const elements = element.parentNode ? Array.from(element.parentNode.children) : [];

                // find and return the index (returns -1 if empty array)
                return elements.indexOf(element);

            }

            // if no elements, return -1
            return -1;

        }

        // if a selector string is provided
        if (typeof selector === 'string') {

            // if we have elements
            if (this.length > 0) {

                // get the first element
                const element = this[0];

                // get all elements matching the selector
                const elements = _query(selector, document);

                // find and return the index
                return elements.indexOf(element);

            }

            // if no elements, return -1
            return -1;

        }

        // if a DOM element is provided
        if (selector instanceof Element)

            // find the index of this element in the current set
            return this.findIndex(el => el === selector);

        // if a ZebraJS object is provided
        if (typeof selector === 'object' && selector.version) {

            // if the object has elements
            if (selector.length > 0)

                // find the index of the first element in the current set
                return this.findIndex(el => el === selector[0]);

            // not found
            return -1;

        }

        // default return
        return -1;

    }

    /**
     *  Checks the current matched set of elements against a selector, element or ZebraJS object and returns `true` if at
     *  least one of these elements matches the given arguments.
     *
     *  Supports both standard CSS selectors and jQuery filtering pseudo-selectors like `:visible`, `:hidden`, `:has()`,
     *  `:contains()`, `:header`, `:input`, etc.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // returns true if the element is a "select" element
     *  console.log(element.is('select'))
     *
     *  @param  {mixed}     selector    A string containing a selector expression to match elements against, a DOM element
     *                                  or a ZebraJS object.
     *
     *  @return {boolean}   Returns `true` if at least one of the elements from the currently matched set matches the given
     *                      argument.
     *
     *  @memberof   ZebraJS
     *  @alias      is
     *  @instance
     */
    $.fn.is = function(selector) {

        let result = false;

        // iterate over the set of matched elements
        this.forEach(element => {

            // if
            if (

                // selector is a CSS selector and the current element matches the selector OR
                (typeof selector === 'string' && _query(selector, element, 'matches')) ||

                // selector is a ZebraJS object and the current element matches the first element in the set of matched elements OR
                (typeof selector === 'object' && selector.version && element === selector[0]) ||

                // selector is a DOM element and current element matches it
                (typeof selector === 'object' && (selector instanceof Document || selector instanceof Element || selector instanceof Text || selector instanceof Window) && element === selector)

            ) {

                // set result to TRUE
                result = true;

                // don't look further
                return false;

            }

        });

        // return result
        return result;

    }

    /**
     *  Inserts every element in the set of matched elements after the parent element(s), specified by the argument.
     *
     *  Both this and the {@link ZebraJS#after .after()} method perform the same task, the main difference being in the
     *  placement of the content and the target. With `.after()`, the selector expression preceding the method is the target
     *  after which the content is to be inserted. On the other hand, with `.insertAfter()`, the content precedes the method,
     *  and it is the one inserted after the target element(s).
     *
     *  > If there is more than one target element, clones of the inserted element will be created after each target except
     *  for the last one. The original item will be inserted after the last target.
     *
     *  > If an element selected this way is inserted elsewhere in the DOM, clones of the inserted element will be created
     *  after each target except for the last one. The original item will be moved (not cloned) after the last target.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const target = $('#selector');
     *
     *  // insert a div that we create on the fly
     *  $('<div>').text('hello').insertAfter(target);
     *
     *  // same thing as above
     *  $('<div>hello</div>').insertAfter(target);
     *
     *  // use one or more elements that already exist on the page
     *  // if "target" is a single element than the list will be moved after the target element
     *  // if "parent" is a collection of elements, clones of the list element will be created after
     *  // each target, except for the last one; the original list will be moved after the last target
     *  $('ul').insertAfter(target);
     *
     *  @param  {ZebraJS}   target  A ZebraJS object after which to insert each element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the ZebraJS object after the content is inserted.
     *
     *  @memberof   ZebraJS
     *  @alias      insertAfter
     *  @instance
     */
    $.fn.insertAfter = function(target) {

        // call the "_dom_insert" private method with these arguments
        return $(target)._dom_insert(this, 'after');

    }

    /**
     *  Inserts every element in the set of matched elements before the parent element(s), specified by the argument.
     *
     *  Both this and the {@link ZebraJS#before .before()} method perform the same task, the main difference being in the
     *  placement of the content and the target. With `.before()`, the selector expression preceding the method is the target
     *  before which the content is to be inserted. On the other hand, with `.insertBefore()`, the content precedes the method,
     *  and it is the one inserted before the target element(s).
     *
     *  > If there is more than one target element, clones of the inserted element will be created before each target except
     *  for the last one. The original item will be inserted before the last target.
     *
     *  > If an element selected this way is inserted elsewhere in the DOM, clones of the inserted element will be created
     *  before each target except for the last one. The original item will be moved (not cloned) before the last target.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const target = $('#selector');
     *
     *  // insert a div that we create on the fly
     *  $('<div>').text('hello').insertBefore(target);
     *
     *  // same thing as above
     *  $('<div>hello</div>').insertBefore(target);
     *
     *  // use one or more elements that already exist on the page
     *  // if "target" is a single element than the list will be moved before the target element
     *  // if "parent" is a collection of elements, clones of the list element will be created before
     *  // each target, except for the last one; the original list will be moved before the last target
     *  $('ul').insertBefore(target);
     *
     *  @param  {ZebraJS}   target  A ZebraJS object before which to insert each element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the ZebraJS object before which the content is inserted.
     *
     *  @memberof   ZebraJS
     *  @alias      insertBefore
     *  @instance
     */
    $.fn.insertBefore = function(target) {

        // call the "_dom_insert" private method with these arguments
        return $(target)._dom_insert(this, 'before');

    }

    /**
     *  Gets the immediately following sibling of each element in the set of matched elements. If a selector is provided,
     *  it retrieves the following sibling only if it matches that selector.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get the next sibling
     *  const next = element.next();
     *
     *  // get the following sibling only if it matches the selector
     *  const next2 = element.next('div');
     *
     *  // chaining
     *  element.next().addClass('foo');
     *
     *  @param  {string}    selector    If the selector is provided, the method will retrieve the following sibling only if
     *                                  it matches the selector
     *
     *  @return {ZebraJS}   Returns the immediately following sibling of each element in the set of matched elements,
     *                      optionally filtered by a selector, as a ZebraJS object.
     *
     *  @memberof   ZebraJS
     *  @alias      next
     *  @instance
     */
    $.fn.next = function(selector) {

        // get the immediately preceding sibling of each element in the set of matched elements,
        // optionally filtered by a selector
        return this._add_prev_object(this._dom_search('next', selector));

    }

    /**
     *  Removes elements from the set of matched elements.
     *
     *  @example
     *
     *  // find all elements having class ".foo" but not ".bar"
     *  $('.foo').not('.bar');
     *
     *  @param  {mixed}     selector    Can be a **string** containing a selector expression, a **DOM** element, an **array
     *                                  of elements** to match against the set, or a **function** used as a test for each
     *                                  element in the set.
     *                                  <br><br>
     *                                  If argument is a function, it accepts two arguments: "index", which is the element's
     *                                  index in the set of matched elements, and "element", which is the DOM element.<br>
     *                                  Within the function, `this` refers to the current DOM element.
     *
     *  @memberof   ZebraJS
     *  @alias      not
     *  @instance
     */
    $.fn.not = function(selector) {

        // iterate over the set of matched elements
        return this._add_prev_object($(this.filter(function(element, index) {

            // if selector is a function, use it to filter results
            if (typeof selector === 'function' && selector.call !== undefined) return selector.call(element, index);

            // if selector is an array of elements
            if (Array.isArray(selector))

                // filter results
                return !selector.filter(current_selector => $(element).is(current_selector)).length;

            // otherwise use "is" to  filter results
            return !$(element).is(selector);

        })));

    }

    /**
     *  Remove an event handler.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // create a function
     *  const callback = e => {
     *      console.log('clicked!');
     *  }
     *
     *  // handle clicks on element using the function created above
     *  element.on('click', callback);
     *
     *  // remove that particular click event
     *  element.off('click', callback);
     *
     *  // remove *all* the click events
     *  element.off('click');
     *
     *  // remove *only* the click events that were namespaced
     *  element.off('click.namespace');
     *
     *  @param  {string}    event_type  One or more space-separated event types and optional namespaces, such as "click" or
     *                                  "click.namespace".
     *
     *  @param  {function}  callback    A function to execute when the event is triggered.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      off
     *  @instance
     */
    $.fn.off = function(event_type, callback) {

        const event_types = event_type ? event_type.split(' ') : [...event_listeners.keys()];
        const remove_all_event_handlers = !event_type;
        let namespace, index, entry, actual_event_type;

        // iterate through the set of matched elements
        this.forEach(element => {

            // iterate through the event types we have to remove the handler from
            event_types.forEach(original_event => {

                // handle namespacing
                namespace = original_event.split('.')
                actual_event_type = namespace[0];
                namespace = namespace[1] || '';

                // if we have registered event of this type
                if (event_listeners.has(actual_event_type)) {

                    const listeners = event_listeners.get(actual_event_type);

                    // iterate through the registered events of this type
                    // we're going backwards to avoid memory leaks while iterating through an array while
                    // simultaneously splicing from it
                    for (index = listeners.length - 1; index >= 0; index--) {

                        entry = listeners[index];

                        // if
                        if (

                            // this is an event registered for the current element
                            entry[0] === element &&

                            // no callback was specified (we need to remove all events of this type) OR
                            // callback is given and we've just found it
                            (undefined === callback || callback === entry[1]) &&

                            // we're looking at the right namespace (or we need to remove all event handlers)
                            (remove_all_event_handlers || namespace === entry[2])

                        ) {

                            // remove the event listener
                            element.removeEventListener(actual_event_type, entry[3] || entry[1]);

                            // remove entry from the event listeners array
                            listeners.splice(index, 1);

                            // if nothing left for this event type then also remove the event type's entry
                            if (listeners.length === 0) event_listeners.delete(actual_event_type);

                            // don't look further
                            return;

                        }

                    }

                }

            });

        });

        // return the set of matched elements, for chaining
        return this;

    }

    /**
     *  Gets the current coordinates of the first element in the set of matched elements, relative to the document.
     *
     *  This method retrieves the current position of an element relative to the document, in contrast with the
     *  {@link ZebraJS#position .position()} method which retrieves the current position relative to the offset parent.
     *
     *  > This method cannot get the position of hidden elements or accounting for borders, margins, or padding set on the
     *  body element.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get the element's position, relative to the offset parent
     *  const offset = element.offset()
     *
     *  @return {object}    Returns an object with the `left` and `top` properties.
     *
     *  @memberof   ZebraJS
     *  @alias      offset
     *  @instance
     */
    $.fn.offset = function() {

        // return now in case of an empty selection
        if (!this[0]) return {
            left: 0,
            top: 0
        };

        // get the bounding box of the first element in the set of matched elements
        const box = this[0].getBoundingClientRect();

        // return the object with the offset
        return {
            left: box.left + window.pageXOffset - document.documentElement.clientLeft,
            top: box.top + window.pageYOffset - document.documentElement.clientTop
        }

    }

    /**
     *  Attaches an event handler function for one or more events to the selected elements.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // create a function
     *  const callback = e => {
     *      console.log('clicked!');
     *  }
     *
     *  // handle clicks on element using the function created above
     *  element.on('click', callback);
     *
     *  // handle clicks on element using an anonymous function
     *  element.on('click', e => {
     *      console.log('clicked!');
     *  });
     *
     *  // namespacing, so that you can remove only certain events
     *  element.on('click.namespace', e => {
     *      console.log('clicked!');
     *  });
     *
     *  // passing data to the event handler
     *  element.on('click', { foo: 'bar' }, e => {
     *      console.log(e.data.foo); // 'bar'
     *  });
     *
     *  // using delegation
     *  // handle clicks on all the "div" elements
     *  // that are children of the element
     *  element.on('click', 'div', e => {
     *      console.log('clicked!');
     *  });
     *
     *  // using delegation with data
     *  element.on('click', 'div', { userId: 123 }, e => {
     *      console.log(e.data.userId); // 123
     *  });
     *
     *  // chaining
     *  element.on('click', () => {
     *      console.log('clicked!');
     *  }).addClass('foo');
     *
     *  // multiple events
     *  element.on({
     *      mouseenter: () => { ... },
     *      mouseleave: () => { ... }
     *  });
     *
     *  @param  {string}    event_type  One or more space-separated event types and optional namespaces, such as "click" or
     *                                  "click.namespace". Can also be given as an object.
     *
     *  @param  {string}    [selector]  A selector string to filter the descendants of the selected elements that will call
     *                                  the handler. If the selector is null or omitted, the handler is always called when it
     *                                  reaches the selected element.
     *
     *  @param  {object}    [data]      Data to be passed to the handler in `event.data` when an event is triggered.
     *
     *  @param  {function}  callback    A function to execute when the event is triggered.
     *
     *  @param  {boolean}   [once]      Whether the callback should be called a single time only
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      on
     *  @instance
     */
    $.fn.on = function(event_type, selector, data, callback, once) {

        let namespace, actual_callback, event_data;

        // if event_type is given as object
        if (typeof event_type === 'object') {

            // iterate over all the events
            for (const i in event_type)

                // bind them
                this.on(i, event_type[i]);

            // don't go forward
            return this;

        }

        // if more than a single event was given
        const event_types = event_type.split(' ');

        // handle optional selector and data
        // case 1: selector is a function - on(event_type, callback)
        if (typeof selector === 'function') {

            // shift parameters
            if (typeof data === 'boolean') once = data;
            callback = selector;
            selector = undefined;
            data = undefined;

        // case 2: selector is an object - on(event_type, data, callback)
        } else if (typeof selector === 'object' && selector !== null && !Array.isArray(selector)) {

            // shift parameters
            event_data = selector;
            if (typeof callback === 'boolean') once = callback;
            if (typeof data === 'function') callback = data;
            selector = undefined;
            data = undefined;

        // case 3: selector is a string
        } else if (typeof selector === 'string')

            // if data is a function - on(event_type, selector, callback)
            if (typeof data === 'function') {

                if (typeof callback === 'boolean') once = callback;
                callback = data;
                data = undefined;

            // if data is an object - on(event_type, selector, data, callback)
            } else if (typeof data === 'object' && data !== null && !Array.isArray(data))

                event_data = data;

        // iterate through the set of matched elements
        this.forEach(element => {

            // iterate through the event types we have to attach the handler to
            event_types.forEach(original_event => {

                actual_callback = false;

                // handle namespacing
                namespace = original_event.split('.')
                event_type = namespace[0];
                namespace = namespace[1] || '';

                // if this is the first time we have this event type
                if (!event_listeners.has(event_type))

                    // initialize the entry for this event type
                    event_listeners.set(event_type, []);

                // if selector is a string
                if (typeof selector === 'string') {

                    // this will be the actual callback function
                    actual_callback = function(e) {

                        // attach data to event object if provided
                        if (event_data) e.data = event_data;

                        // if the callback needs to be executed only once, remove it now
                        if (once) $(this).off(original_event, callback);

                        // walk up the DOM tree from e.target to "this" (the element the listener is attached to)
                        // to find an element that matches the selector
                        let target = e.target;

                        // as long as we didn't yet find the element the listener is attached to
                        while (target && target !== this) {

                            // if the element matches the selector
                            if (_query(selector, target, 'matches')) {

                                // call the callback with the matched element as 'this'
                                callback.call(target, e);

                                // stop after first match (don't continue bubbling up)
                                return;

                            }

                            // continue walking up the DOM tree
                            target = target.parentNode;

                        }

                    };

                    // attach event listener
                    element.addEventListener(event_type, actual_callback);

                // if the callback needs to be executed only once
                } else if (once) {

                    // the actual callback function
                    actual_callback = function(e) {

                        // attach data to event object if provided
                        if (event_data) e.data = event_data;

                        // remove the event handler
                        $(this).off(original_event, callback);

                        // execute the callback function
                        callback.call(this, e);

                    }

                    // set the event listener
                    element.addEventListener(event_type, actual_callback);

                // registering of default event listeners
                } else

                    // if we have event data, wrap the callback
                    if (event_data) {

                        actual_callback = function(e) {

                            // attach data to event object
                            e.data = event_data;

                            // execute the callback function
                            callback.call(this, e);

                        };

                        element.addEventListener(event_type, actual_callback);

                    // no event data, register callback directly
                    } else element.addEventListener(event_type, callback);

                // add element/callback combination to the array of events of this type
                event_listeners.get(event_type).push([element, callback, namespace, actual_callback]);

            });

        });

        // return the set of matched elements, for chaining
        return this;

    }

    /**
     *  Attaches an event handler function for one or more events to the selected elements. The event handler is executed at
     *  most once per element per event type.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // create a function
     *  const callback = function(e) {
     *      console.log('clicked!');
     *  }
     *
     *  // handle clicks on element using the function created above
     *  // (the callback will be executed only once)
     *  element.one('click', callback);
     *
     *  // handle clicks on element using an anonymous function
     *  // (the callback will be executed only once)
     *  element.one('click', function(e) {
     *      console.log('clicked!');
     *  });
     *
     *  // namespacing, so that you can remove only certain events
     *  // (the callback will be executed only once)
     *  element.one('click.namespace', function(e) {
     *      console.log('clicked!');
     *  });
     *
     *  // using delegation
     *  // handle clicks on all the "div" elements
     *  // that are children of the element
     *  // (the callback will be executed only once for each matched element)
     *  element.one('click', 'div', function(e) {
     *      console.log('clicked!');
     *  });
     *
     *  // chaining
     *  element.one('click', function() {
     *      console.log('clicked!');
     *  }).addClass('foo');
     *
     *  @param  {string}    event_type  One or more space-separated event types and optional namespaces, such as "click" or
     *                                  "click.namespace".
     *
     *  @param  {string}    [selector]  A selector string to filter the descendants of the selected elements that will call
     *                                  the handler. If the selector is null or omitted, the handler is always called when it
     *                                  reaches the selected element.
     *
     *  @param  {function}  callback    A function to execute when the event is triggered.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      one
     *  @instance
     */
    $.fn.one = function(event_type, selector, callback) {

        // call the "on" method with last argument set to TRUE
        return this.on(event_type, selector, callback, true);

    }

    /**
     *  Returns the height (including `padding`, `border` and, optionally, `margin`) for the first element in the set of
     *  matched elements.
     *
     *  > For hidden elements the returned value is `0`!
     *
     *  See {@link ZebraJS#height .height()} for getting the **inner** height without `padding`, `border` and `margin`.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('selector');
     *
     *  // get the element's outer height
     *  const height = element.outerHeight();
     *
     *  @param  {boolean}   [include_margins]   If set to `TRUE`, the result will also include **top** and **bottom**
     *                                          margins.
     *
     *  @return {float}
     *
     *  @memberof   ZebraJS
     *  @alias      outerHeight
     *  @instance
     */
    $.fn.outerHeight = function(include_margins) {

        // get computed styles only if we need margins
        const computed_styles = include_margins ? window.getComputedStyle(this[0]) : null;

        // return outer height (content + padding + border)
        return this[0].offsetHeight +

            // include margins, if requested
            (include_margins ? parseFloat(computed_styles.marginTop) + parseFloat(computed_styles.marginBottom) : 0);

    }

    /**
     *  Returns the width (including `padding`, `border` and, optionally, `margin`) for the first element in the set of
     *  matched elements.
     *
     *  > For hidden elements the returned value is `0`!
     *
     *  See {@link ZebraJS#width .width()} for getting the **inner** width without `padding`, `border` and `margin`.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('selector');
     *
     *  // get the element's outer width
     *  const height = element.outerWidth();
     *
     *  @param  {boolean}   [include_margins]   If set to `TRUE`, the result will also include **left** and **right**
     *                                          margins.
     *
     *  @return {float}
     *
     *  @memberof   ZebraJS
     *  @alias      outerWidth
     *  @instance
     */
    $.fn.outerWidth = function(include_margins) {

        // get computed styles only if we need to include margins
        const computed_styles = include_margins ? window.getComputedStyle(this[0]) : null;

        // return outer width (content + padding + border)
        return this[0].offsetWidth +

            // include margins, if requested
            (include_margins ? parseFloat(computed_styles.marginLeft) + parseFloat(computed_styles.marginRight) : 0);

    }

    /**
     *  Gets the immediate parent of each element in the current set of matched elements, optionally filtered by a selector.
     *
     *  This method is similar to {@link ZebraJS#parents .parents()}, except .parent() only travels a single level up the
     *  DOM tree.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get the element's parent
     *  const parent = element.parent();
     *
     *  // get the element's parent *only* if it is a div
     *  const parent2 = element.parent('div');
     *
     *  // chaining
     *  element.parent().addClass('foo');
     *
     *  @param  {string}    selector    If the selector is supplied, the elements will be filtered by testing whether they
     *                                  match it.
     *
     *  @return {ZebraJS}   Returns the immediate parent of each element in the current set of matched elements, optionally
     *                      filtered by a selector, as a ZebraJS object.
     *
     *  @memberof   ZebraJS
     *  @alias      parent
     *  @instance
     */
    $.fn.parent = function(selector) {

        const result = [];

        // iterate through the set of matched elements
        this.forEach(element => {

            // if not a detached element, no selector is provided or it is and the parent matches it, add element to the array
            if (element.parentNode && (!selector || _query(selector, element.parentNode, 'matches'))) result.push(element.parentNode);

        });

        // return the resulting array
        return this._add_prev_object($(result));

    }

    /**
     *  Gets the ancestors of each element in the current set of matched elements, optionally filtered by a selector.
     *
     *  Given a {@link ZebraJS} object that represents a set of DOM elements, this method allows us to search through the
     *  ancestors of these elements in the DOM tree and construct a new {@link ZebraJS} object from the matching elements
     *  ordered from immediate parent on up; the elements are returned in order from the closest parent to the outer ones.
     *  When multiple DOM elements are in the original set, the resulting set will have duplicates removed.
     *
     *  This method is similar to {@link ZebraJS#parent .parent()}, except .parent() only travels a single level up the DOM
     *  tree, while this method travels all the way up to the DOM root.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get *all* the element's parent
     *  const parents = element.parents();
     *
     *  // get all the element's parent until the first div (including also that first div)
     *  const parents2 = element.parents('div');
     *
     *  // chaining
     *  element.parents('div').addClass('foo');
     *
     *  @param  {string}    selector    If the selector is supplied, the parents will be filtered by testing whether they
     *                                  match it.
     *
     *  @return {ZebraJS}   Returns an array of parents of each element in the current set of matched elements, optionally
     *                      filtered by a selector, as a ZebraJS object.
     *
     *  @memberof   ZebraJS
     *  @alias      parents
     *  @instance
     */
    $.fn.parents = function(selector) {

        const result = [];

        // iterate through the set of matched elements
        this.forEach(element => {

            // unless we got to the root of the DOM, get the element's parent
            while (!((element = element.parentNode) instanceof Document)) {

                // if not already in the array, add parent to the results array
                if (!result.includes(element)) result.push(element);

                // if selector was specified and element matches it, don't look any further
                if (selector && _query(selector, element, 'matches')) break;

            }

        });

        // return the matched elements, as a ZebraJS object
        return this._add_prev_object($(result));

    }

    /**
     *  Gets the current coordinates of the first element in the set of matched elements, relative to the offset parent.
     *
     *  This method retrieves the current position of an element relative to the offset parent, in contrast with the
     *  {@link ZebraJS#offset .offset()} method which retrieves the current position relative to the document.
     *
     *  > This method cannot get the position of hidden elements or accounting for borders, margins, or padding set on the
     *  body element.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get the element's position, relative to the offset parent
     *  const position = element.position()
     *
     *  @return {object}    Returns an object with the `left` and `top` properties.
     *
     *  @memberof   ZebraJS
     *  @alias      position
     *  @instance
     */
    $.fn.position = function() {

        // return now in case of an empty selection
        if (!this[0]) return {
            left: 0,
            top: 0
        };

        // return the position of the first element in the set of matched elements
        return {
            left: parseFloat(this[0].offsetLeft),
            top: parseFloat(this[0].offsetTop)
        }

    }

    /**
     *  Inserts content, specified by the argument, to the beginning of each element in the set of matched elements.
     *
     *  Both this and the {@link ZebraJS#prependTo .prependTo()} method perform the same task, the main difference being in
     *  the placement of the content and the target. With `.prepend()`, the selector expression preceding the method is the
     *  container into which the content is to be inserted. On the other hand, with `.prependTo()`, the content precedes the
     *  method, and it is inserted into the target container.
     *
     *  > If there is more than one target element, clones of the inserted element will be created for each target except for
     *  the last one. For the last target, the original item will be inserted.
     *
     *  > If an element selected this way is inserted elsewhere in the DOM, clones of the inserted element will be created for
     *  each target except for the last one. For the last target, the original item will be moved (not cloned).
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const parent = $('#selector');
     *
     *  // append a div that we create on the fly
     *  parent.prepend($('<div>').text('hello'));
     *
     *  // same thing as above
     *  parent.prepend($('<div>hello</div>'));
     *
     *  // prepend one or more elements that already exist on the page
     *  // if "parent" is a single element than the list will be moved inside the parent element
     *  // if "parent" is a collection of elements, clones of the list element will be created for
     *  // each target except for the last one; for the last target, the original list will be moved
     *  parent.prepend($('ul'));
     *
     *  // prepend a string (which will be transformed in HTML)
     *  // this is more efficient memory wise
     *  parent.prepend('<div>hello</div>');
     *
     *  // chaining
     *  parent.prepend($('div')).addClass('foo');
     *
     *  @param  {mixed}     content     DOM element, text node, HTML string, or {@link ZebraJS} object to insert at the
     *                                  beginning of each element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements (the parents, not the prepended elements).
     *
     *  @memberof   ZebraJS
     *  @alias      prepend
     *  @instance
     */
    $.fn.prepend = function(content) {

        // call the "_dom_insert" private method with these arguments
        return this._dom_insert(content, 'prepend');

    }

    /**
     *  Inserts every element in the set of matched elements to the beginning of the parent element(s), specified by the argument.
     *
     *  Both this and the {@link ZebraJS#prepend .prepend()} method perform the same task, the main difference being in the
     *  placement of the content and the target. With `.prepend()`, the selector expression preceding the method is the
     *  container into which the content is to be inserted. On the other hand, with `.prependTo()`, the content precedes the
     *  method, and it is inserted into the target container.
     *
     *  > If there is more than one target element, clones of the inserted element will be created for each target except for
     *  the last one. For the last target, the original item will be inserted.
     *
     *  > If an element selected this way is inserted elsewhere in the DOM, clones of the inserted element will be created for
     *  each target except for the last one. For the last target, the original item will be moved (not cloned).
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const parent = $('#selector');
     *
     *  // prepend a div that we create on the fly
     *  $('<div>').text('hello').prependTo(parent);
     *
     *  // same thing as above
     *  $('<div>hello</div>').prependTo(parent);
     *
     *  // prepend one or more elements that already exist on the page
     *  // if "parent" is a single element than the list will be moved inside the parent element
     *  // if "parent" is a collection of elements, clones of the list element will be created for
     *  // each target except for the last one; for the last target, the original list will be moved
     *  $('ul').appendTo(parent);
     *
     *  @param  {ZebraJS}   parent      A ZebraJS object at beginning of which to insert each element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the ZebraJS object you are appending to.
     *
     *  @memberof   ZebraJS
     *  @alias      prependTo
     *  @instance
     */
    $.fn.prependTo = function(parent) {

        // call the "_dom_insert" private method with these arguments
        return $(parent)._dom_insert(this, 'prepend');

    }

    /**
     *  Gets the immediately preceding sibling of each element in the set of matched elements. If a selector is provided,
     *  it retrieves the previous sibling only if it matches that selector.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get the previous sibling
     *  const prev = element.prev();
     *
     *  // get the previous sibling only if it matches the selector
     *  const prev2 = element.prev('div');
     *
     *  // since this method returns a ZebraJS object
     *  element.prev().addClass('foo');
     *
     *  @param  {string}    selector    If the selector is provided, the method will retrieve the previous sibling only if
     *                                  it matches the selector
     *
     *  @return {ZebraJS}   Returns the immediately preceding sibling of each element in the set of matched elements,
     *                      optionally filtered by a selector, as a ZebraJS object.
     *
     *  @memberof   ZebraJS
     *  @alias      prev
     *  @instance
     */
    $.fn.prev = function(selector) {

        // get the immediately preceding sibling of each element in the set of matched elements,
        // optionally filtered by a selector
        return this._add_prev_object(this._dom_search('previous', selector));

    }

    /**
     *  Specifies a function to execute when the DOM is fully loaded.
     *
     *  @example
     *
     *  $(document).ready(function() {
     *      // code to be executed when the DOM is ready
     *  });
     *
     *  @param  {function}  callback    A function to execute when the DOM is ready and safe to manipulate.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      ready
     *  @instance
     */
    $.fn.ready = function(callback) {

        // if DOM is already ready, fire the callback now
        if (document.readyState === 'complete' || document.readyState !== 'loading') callback();

        // otherwise, wait for the DOM and execute the callback when the it is ready
        else document.addEventListener('DOMContentLoaded', callback);

        // return the set of matched elements
        return this;

    }

    /**
     *  Removes the set of matched elements from the DOM.
     *
     *  Use this method when you want to remove the element itself, as well as everything inside it. In addition to the elements
     *  themselves, all attached event handlers and data attributes associated with the elements are also removed.
     *
     *  To remove the elements without removing data and event handlers, use {@link ZebraJS#detach() .detach()} instead.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // remove the element, its children, and all attached event
     *  // handlers and data attributes associated with the elements
     *  element.remove();
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      remove
     *  @instance
    */
    $.fn.remove = function() {

        // iterate over the set of matched elements
        this.forEach(element => {

            // the element as a ZebraJS object
            const $element = $(element);

            // the element's children
            const children = Array.from(element.querySelectorAll('*'));

            // iterate over the element's children
            children.forEach(child => {

                // the child's ZebraJS form
                let $child = $(child);

                // remove all event handlers
                $child.off();

                // nullify the child to free memory
                $child = null;

            });

            // remove all attached event handlers
            $element.off();

            // remove element from the DOM (including children)
            if (element.parentNode) element.parentNode.removeChild(element);

        });

        // return the set of matched elements
        return this;

    }

    /**
     *  Removes a previously-stored piece of data from the matched elements.
     *
     *  The `.removeData()` method allows us to remove values that were previously set using `.data()`. When called with
     *  the name of a key, it removes that particular value. When called without any arguments, it removes all data.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // set some data
     *  element.data('foo', 'bar');
     *  element.data('baz', {key: 'value'});
     *
     *  // remove specific data
     *  element.removeData('foo');
     *
     *  // remove all data
     *  element.removeData();
     *
     *  @param  {string}    [name]  A string naming the piece of data to remove. If omitted, all data will be removed.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      removeData
     *  @instance
     */
    $.fn.removeData = function(name) {

        // iterate through the set of matched elements
        this.forEach(element => {

            // if a specific name is provided
            if (undefined !== name) {

                // make sure the name follows the Dataset API specs
                // http://www.w3.org/TR/html5/dom.html#dom-dataset
                name = name

                    // replace "-" followed by an ascii letter to that letter in uppercase
                    .replace(/\-([a-z])/ig, (match, letter) => letter.toUpperCase())

                    // remove any left "-"
                    .replace(/\-/g, '');

                // try to remove from WeakMap storage
                if ($._data_storage) {

                    const element_data = $._data_storage.get(element);

                    // if we have data for this element
                    if (element_data && element_data[name] !== undefined)

                        // remove the specific property
                        delete element_data[name];

                }

                // try to remove from dataset
                if (element.dataset && element.dataset[name] !== undefined)

                    // remove the data attribute
                    delete element.dataset[name];

            // if no name is provided, remove all data
            } else {

                // remove all WeakMap data for this element
                if ($._data_storage) $._data_storage.delete(element);

                // remove all dataset attributes
                if (element.dataset) {

                    // get all data attribute names
                    const keys = Object.keys(element.dataset);

                    // remove each one
                    keys.forEach(key => {
                        delete element.dataset[key];
                    });

                }

            }

        });

        // return the set of matched elements, for chaining
        return this;

    }

    /**
     *  Removes one or more classes from each element in the set of matched elements.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // remove a single class
     *  elements.removeClass('foo');
     *
     *  // remove multiple classes
     *  elements.removeClass('foo baz');
     *
     *  // since this method returns the set of matched elements
     *  elements.removeClass('foo baz').css('display', 'none');
     *
     *  @param  {string}    class_names One or more space-separated class names to be removed from each element in
     *                                  the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      removeClass
     *  @instance
     */
    $.fn.removeClass = function(class_names) {

        // remove class(es) and return the set of matched elements
        return this._class('remove', class_names);

    }

    /**
     *  Replaces each element in the set of matched elements with the provided new content and returns the set of elements
     *  that was removed.
     *
     *  > Note that if the method's argument is a selector, then clones of the element described by the selector will be
     *  created and used for replacing each element in the set of matched elements, except for the last one. The original
     *  item will be moved (not cloned) and used to replace the last target.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // wrap element in a div
     *  element.replaceWith('<div id="replacement"></div>');
     *
     *  // *exactly* the same thing as above
     *  element.replaceWith($('<div id="replacement"></div>'));
     *
     *  // using an existing element as the wrapper
     *  element.replaceWith($('#element-from-the-page'));
     *
     *  @param  {mixed} element     A string, a {@link ZebraJS} object or a DOM element to use as replacement for each
     *                              element in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      replaceWith
     *  @instance
     */
    $.fn.replaceWith = function(element) {

        // call the "_dom_insert" private method with these arguments
        return this._dom_insert(element, 'replace');

    }

    /**
     *  Gets the horizontal position of the scrollbar for the first element in the set of matched elements, or sets the
     *  horizontal position of the scrollbar for every matched element.
     *
     *  The horizontal scroll position is the same as the number of pixels that are hidden from view above the scrollable area.
     *  If the scroll bar is at the very left, or if the element is not scrollable, this number will be `0`.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const body = $('body');
     *
     *  // get the horizontal scroll of the body
     *  body.scrollLeft();
     *
     *  // set the horizontal scroll of the body
     *  body.scrollLeft(250);
     *
     *  // chaining
     *  elements.scrollLeft(250).addClass('foo');
     *
     *  @param  {integer}   [value]     Sets the horizontal position of the scrollbar for every matched element.
     *
     *  @return {ZebraJS|integer}       When `setting` the horizontal position, this method returns the set of matched elements.
     *                                  When `reading` the horizontal position, this method returns the horizontal position of
     *                                  the scrollbar for the first element in the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      scrollLeft
     *  @instance
     */
    $.fn.scrollLeft = function(value) {

        // if value is not specified, return the scrollLeft value of the first element in the set of matched elements
        if (undefined === value) return this[0] instanceof Window || this[0] instanceof Document ? document.documentElement.scrollLeft : this[0].scrollLeft;

        // iterate through the set of matched elements
        this.forEach(element => {

            // set the scrollLeft value for each element
            // apply "parseFloat" in case is provided as string or suffixed with "px"
            element.scrollLeft = parseFloat(value);

        });

        // return the matched elements
        return this;

    }

    /**
     *  Gets the vertical position of the scrollbar for the first element in the set of matched elements, or sets the
     *  vertical position of the scrollbar for every matched element.
     *
     *  The vertical scroll position is the same as the number of pixels that are hidden from view above the scrollable area.
     *  If the scroll bar is at the very top, or if the element is not scrollable, this number will be `0`.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const body = $('body');
     *
     *  // get the vertical scroll of the body
     *  body.scrollTop();
     *
     *  // set the vertical scroll of the body
     *  body.scrollTop(250);
     *
     *  // chaining
     *  elements.scrollTop(250).addClass('foo');
     *
     *  @param  {integer}   [value]     Sets the vertical position of the scrollbar for every matched element.
     *
     *  @return {ZebraJS|integer}       When `setting` the vertical position, this method returns the set of matched elements.
     *                                  When `reading` the vertical position, this method returns the vertical position of
     *                                  the scrollbar for the first element in the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      scrollTop
     *  @instance
     */
    $.fn.scrollTop = function(value) {

        // if value is not specified, return the scrollTop value of the first element in the set of matched elements
        if (undefined === value) return this[0] instanceof Window || this[0] instanceof Document ? document.documentElement.scrollTop : this[0].scrollTop;

        // iterate through the set of matched elements
        this.forEach(element => {

            // set the scrollTop value for each element
            // apply "parseFloat" in case is provided as string or suffixed with "px"
            element.scrollTop = parseFloat(value);

        });

        // return the matched elements
        return this;

    }

    /**
     *  If the first element in the set of matched elements is a `form` element, this method returns the encodes string of
     *  the form's elements and their respective values.
     *
     *  > Only "successful controls" are serialized to the string. No submit button value is serialized since the form was
     *  not submitted using a button. For a form element's value to be included in the serialized string, the element must
     *  have a name attribute. Values from checkboxes and radio buttons (inputs of type "radio" or "checkbox") are included
     *  only if they are checked. Data from file select elements is not serialized. Image inputs (`type="image"`) are
     *  serialized by their value attribute if present, but click coordinates (.x/.y) are not included since this is
     *  programmatic serialization, not an actual form submission.
     *
     *  This method creates a text string in standard URL-encoded notation.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const form = $('#form');
     *
     *  // serialize form's elements and their values
     *  const serialized = form.serialize();
     *
     *  @return {string}    Returns the serialized form as a query string that could be sent to a server in an Ajax request.
     *
     *  @memberof   ZebraJS
     *  @alias      serialize
     *  @instance
     */
    $.fn.serialize = function() {

        // return quickly if an empty selection
        if (!this[0]) return '';

        const form = this[0];
        const result = [];

        // if element is a form
        if (typeof form === 'object' && form.nodeName.toUpperCase() === 'FORM')

            // iterate over the form's elements
            Array.from(form.elements).forEach(control => {

                // if element has a name, it is not disabled and it is not a "file", a "reset", a "submit" not a "button"
                if (control.name && !control.disabled && !['file', 'reset', 'submit', 'button'].includes(control.type))

                    // if element is a multiple select
                    if (control.type === 'select-multiple')

                        // iterate over the available options
                        Array.from(control.options).forEach(option => {

                            // add each selected option to the result
                            if (option.selected) result.push(encodeURIComponent(control.name) + '=' + encodeURIComponent(option.value))

                        });

                    // if not a radio or a checkbox, or a checked radio/checkbox
                    else if (!['checkbox', 'radio'].includes(control.type) || control.checked)

                        // add to result
                        result.push(encodeURIComponent(control.name) + '=' + encodeURIComponent(control.value));

            });

        // return the serialized result
        return result.join('&').replace(/\%20/g, '+');

    }

    /**
     *  Sets an element's "display" property to `` (an empty string).
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('selector');
     *
     *  // make element visible in the DOM
     *  element.show();
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      show
     *  @instance
     */
    $.fn.show = function() {

        // iterate through the set of matched elements
        this.forEach(element => {

            // unset the "display" property
            element.style.display = '';

        });

        // return the set of matched elements
        return this;

    }

    /**
     *  Stops the currently-running animation on the matched elements.
     *
     *  When `.stop()` is called on an element, the currently-running animation (if any) is immediately stopped. If an
     *  element is being animated, the animation stops in its current position. If `jump_to_end` is set to `true`, the
     *  animation will jump to its end position.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('#selector');
     *
     *  // start an animation
     *  elements.animate({
     *      left: '+=100px',
     *      opacity: 0.5
     *  }, 1000);
     *
     *  // stop the animation at current position
     *  elements.stop();
     *
     *  // stop and jump to end position
     *  elements.stop(false, true);
     *
     *  @param  {boolean}   [clear_queue]   This parameter is included for jQuery compatibility but is currently not used
     *                                      as ZebraJS does not implement animation queuing. Reserved for future use.
     *                                      <br><br>
     *                                      Default is `false`
     *
     *  @param  {boolean}   [jump_to_end]   A boolean indicating whether to complete the current animation immediately.
     *                                      When `true`, the animation will jump to its end state. When `false` or omitted,
     *                                      the animation stops at its current position.
     *                                      <br><br>
     *                                      Default is `false`
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      stop
     *  @instance
     */
    $.fn.stop = function(clear_queue, jump_to_end) {

        // iterate over the set of matched elements
        this.forEach(element => {

            let property, transition_property, properties_list;
            const $element = $(element);

            // if for whatever reason we don't have this property initialized stop now
            if (!$._data_storage) return;

            // get animation data stored by animate() method
            const animation_data = $._data_storage.get(element);

            // if no animation data found, nothing to stop
            if (!animation_data || !animation_data.zjs_animating) return;

            // get current computed styles to freeze or jump
            const computed_style = window.getComputedStyle(element);

            // remove the "transitionend" event listener
            if (animation_data.zjs_animation_cleanup) $element.off('transitionend', animation_data.zjs_animation_cleanup);

            // clear the timeout fallback
            if (animation_data.zjs_animation_timeout) clearTimeout(animation_data.zjs_animation_timeout);

            // stop the transition by setting it to 'none'
            element.style.transition = 'none';

            // if we need to jump_to_end
            if (jump_to_end && animation_data.zjs_animation_properties)

                // jump to end: apply the target (end) properties
                for (property in animation_data.zjs_animation_properties)
                    element.style[property] = animation_data.zjs_animation_properties[property];

            // if we need to "freeze" the animation at current position
            // (apply computed values as inline styles)
            else {

                // get the list of properties being transitioned
                transition_property = computed_style.transitionProperty;

                // if transition property is set
                if (transition_property && transition_property !== 'none') {

                    // split into individual properties
                    properties_list = transition_property.split(', ');

                    // apply current computed values
                    properties_list.forEach(prop => {

                        // skip 'all' and 'none' keywords
                        if (prop !== 'all' && prop !== 'none') {

                            // convert CSS property name (e.g., 'margin-left') to camelCase (e.g., 'marginLeft')
                            prop = prop.replace(/\-([a-z])/g, (match, letter) => letter.toUpperCase());

                            // apply the current computed value
                            element.style[prop] = computed_style[prop];

                        }

                    });

                }

            }

            // force a reflow to apply the changes immediately
            void element.offsetHeight;

            // reset transition property for future animations
            element.style.transition = '';

            // clean up animation data
            animation_data.zjs_animating = false;
            animation_data.zjs_animation_properties = null;
            animation_data.zjs_animation_cleanup = null;
            animation_data.zjs_animation_timeout = null;

        });

        return this;

    }

    /**
     *  Gets the siblings of each element in the set of matched elements, optionally filtered by a selector.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // get all the siblings of the element
     *  const siblings_all = element.siblings();
     *
     *  // get all the "div" siblings of the element
     *  const siblings_filtered = element.siblings('div');
     *
     *  // since this method returns a ZebraJS object
     *  element.siblings('div').addClass('foo');
     *
     *  @param  {string}    selector    If the selector is supplied, the elements will be filtered by testing whether they
     *                                  match it.
     *
     *  @return {ZebraJS}   Returns the siblings of each element in the set of matched elements, as a ZebraJS object
     *
     *  @memberof   ZebraJS
     *  @alias      siblings
     *  @instance
     */
    $.fn.siblings = function(selector) {

        // get the siblings of each element in the set of matched elements, optionally filtered by a selector.
        return this._add_prev_object(this._dom_search('siblings', selector));

    }

    /**
     *  Gets the text content of the first element in the set of matched elements (combined with the text content of all its
     *  descendants), or sets the text contents of the matched elements.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // set the text content for all the matched elements
     *  elements.text('Hello');
     *
     *  // get the text content of the first element in the
     *  // set of matched elements (including its descendants)
     *  const content = elements.text();
     *
     *  // chaining
     *  elements.text('Hello').addClass('foo');

     *  @param  {string}    [content]   The text to set as the content of all the matched elements. Note that any text
     *                                  content that was previously in that element is completely replaced by the new
     *                                  content.
     *
     *  @return {ZebraJS|string}        When the `content` argument is provided, this method returns the set of matched
     *                                  elements. Otherwise it returns the text content of the first element in the set of
     *                                  matched elements (combined with the text content of all its descendants)
     *
     *  @memberof   ZebraJS
     *  @alias      text
     *  @instance
     */
    $.fn.text = function(content) {

        // if content is provided
        if (undefined !== content)

            // iterate through the set of matched elements
            this.forEach(element => {

                // set the text content of each element
                element.textContent = content;

            });

        // if content is not provided
        // return the text content of the first element in the set of matched elements
        // (combined with the text content of all its descendants)
        else return this[0] ? this[0].textContent : undefined;

        // return the set of matched elements
        return this;

    }

    /**
     *  Adds or removes one or more classes from each element in the set of matched elements, depending on the presence of
     *  each class name given as argument.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // set a random class
     *  elements.addClass('foo');
     *
     *  // toggle classes
     *  // the result will be that "foo" will be removed from the matched elements while the "baz" will be added
     *  elements.toggleClass('foo baz');
     *
     *  // chaining
     *  elements.toggleClass('foo').css('display', 'none');
     *
     *  @param  {string}    class_names One or more space-separated class names to be toggled for each element in the set of
     *                                  matched elements.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      toggleClass
     *  @instance
     */
    $.fn.toggleClass = function(class_names) {

        // toggle class(es) and return the set of matched elements
        return this._class('toggle', class_names);

    }

    /**
     *  Execute all handlers attached to the matched elements for the given event type, in the same order they would be if
     *  the event were triggered naturally by the user.
     *
     *  `.trigger()`ed events bubble up the DOM tree; an event handler can stop the bubbling by returning `false` from the
     *  handler or calling the `.stopPropagation()` method on the event object passed into the event.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // handle clicks on element
     *  element.on('click', function(e) {
     *
     *      // will return "undefined" when element is clicked
     *      // but will return "baz" when triggered manually
     *      console.log(e.foo)
     *
     *  });
     *
     *  // manually trigger the click event
     *  element.trigger('click', {foo: 'baz'});
     *
     *  // chaining
     *  element.trigger('click', {foo: 'baz'}).addClass('foo');
     *
     *  @param  {string}    event_type  A string containing a JavaScript event type, such as `click` or `submit`.
     *
     *  @param  {object}    data        Additional parameters to pass along to the event handler.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      trigger
     *  @instance
     */
    $.fn.trigger = function(event_type, data) {

        // iterate through the set of matched elements
        this.forEach(element => {

            // create the event
            // the event will bubble and it is cancelable
            const event = new Event(event_type, {
                bubbles: true,
                cancelable: true
            });

            // if data is specified and is an object
            if (typeof data === 'object')

                // iterate over the object's keys
                Object.keys(data).forEach(key => {

                    // attach them to the event object
                    event[key] = data[key];

                });

            // dispatch the event
            element.dispatchEvent(event);

        });

        // return the set of matched elements, for chaining
        return this;

    }

    /**
     *  Removes the parents of the set of matched elements from the DOM, leaving the matched elements in their place.
     *
     *  This method is effectively the inverse of the {@link ZebraJS#wrap .wrap()} method. The matched elements (and their
     *  siblings, if any) replace their parents within the DOM structure.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // unwrap the element, whatever its parent may be
     *  element.unwrap();
     *
     *  // unwrap only if the element's parent is a div
     *  element.unwrap('div');
     *
     *  @param  {string}    selector    If the selector is supplied, the parent elements will be filtered and the unwrapping
     *                                  will occur only they match it.
     *
     *  @return {ZebraJS}   Returns the set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      unwrap
     *  @instance
     */
    $.fn.unwrap = function(selector) {

        // iterate through the set of matched elements
        this.forEach(element => {

            // get the element's parent, optionally filtered by a selector,
            // and replace it with the element
            $(element).parent(selector).replaceWith(element);

        });

        // return the set of matched elements
        return this;

    }

    /**
     *  Gets the current value of the first element in the set of matched elements or set the value of every matched element.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('selector');
     *
     *  // get the value of the first element in the list of matched elements
     *  // (if "element" was a select box with multiple selections allowed,
     *  // the returned value would be an array)
     *  const value = element.val();
     *
     *  // set the element's value
     *  element.val('foo');
     *
     *  // setting multiple values for multi-selects and checkboxes
     *  element.val(['option1', 'option2']);
     *
     *  @param  {mixed}     [value]     A string, a number, or an array of strings corresponding to the value of each matched
     *                                  element to set as selected/checked.
     *
     *  @return {ZebraJS|mixed}         If setting a value, this method returns the set of matched elements. If called without
     *                                  the argument, the method return the current value of the first element in the set of
     *                                  matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      val
     *  @instance
     */
    $.fn.val = function(value) {

        const result = [];

        // if "value" argument is not specified
        if (undefined === value) {

            // if first element in the list of matched elements is a select box with the "multiple" attribute set
            if (this[0] && this[0].tagName.toLowerCase() === 'select' && this[0].multiple) {

                // add each selected option to the results array
                Array.from(this[0].options).forEach(elem => {

                    if (elem.selected && !elem.disabled) result.push(elem.value)

                });

                // return the values of selected options
                return result;

            }

            // for other elements, return the first element's value
            return this[0] ? this[0].value : '';

        }

        // if "value" argument is specified
        // iterate through the set of matched elements
        this.forEach(element => {

            // if value is not an array
            if (!Array.isArray(value))

                // set the value of of the current element
                element.value = value;

            // if value is an array, the current element is an checkbox/radio input and its value is in the array
            else if (element.tagName.toLowerCase() === 'input' && element.type && (element.type === 'checkbox' || element.type === 'radio') && element.value && value.includes(element.value))

                // mark the element as checked
                element.checked = true;

            // if element is a select box with the "multiple" attribute set
            else if (element.tagName.toLowerCase() === 'select' && element.multiple)

                // set the "selected" attribute to each matching option
                Array.from(element.options).forEach(elem => {

                    if (value.includes(elem.value)) elem.selected = true;

                });

        });

        // return the set of matched elements
        return this;

    }

    /**
     *  Returns the content width (without `padding`, `border` and `margin`) of the first element in the set of matched
     *  elements as `float`, or sets the `width` CSS property of every element in the set.
     *
     *  See {@link ZebraJS#outerWidth .outerWidth()} for getting the width including `padding`, `border` and, optionally,
     *  `margin`.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const elements = $('selector');
     *
     *  // returns the content width of the first element in the set of matched elements
     *  elements.width();
     *
     *  // sets the "width" CSS property of all elements in the set to 200px
     *  elements.width(200);
     *  elements.width('200');
     *  elements.width('200px');
     *
     *  // sets the "width" CSS property of all elements in the set to 5em
     *  elements.width('5em');
     *
     *  // chaining
     *  elements.width(200).addClass('foo');
     *
     *  @param  {undefined|number|string}   [width]     If not given, this method will return content width (without `padding`,
     *                                                  `border` and `margin`) of the first element in the set of matched
     *                                                  elements.
     *                                                  <br><br>
     *                                                  If given, this method will set the `width` CSS property of all
     *                                                  the elements in the set to that particular value, making sure
     *                                                  to apply the "px" suffix if not otherwise specified.
     *
     *  > For hidden elements the returned value is `0`!
     *
     *  @return {ZebraJS|float}     When **setting** the `width`, this method returns the set of matched elements. Otherwise,
     *                              it returns the content width (without `padding`, `border` and `margin`) of the first
     *                              element in the set of matched elements, as `float`.
     *
     *  @memberof   ZebraJS
     *  @alias      width
     *  @instance
     */
    $.fn.width = function(width) {

        // if "width" is given, set the width of every matched element, making sure to suffix the value with "px"
        // if not otherwise specified
        if (width !== undefined) return this.css('width', width + (parseFloat(width) === width ? 'px' : ''));

        // for the "window"
        if (this[0] === window) return window.innerWidth;

        // for the "document"
        if (this[0] === document)

            // return width
            return Math.max(
                document.body.offsetWidth,
                document.body.scrollWidth,
                document.documentElement.clientWidth,
                document.documentElement.offsetWidth,
                document.documentElement.scrollWidth
            );

        // get the first element's width, left/right padding and borders
        const styles = window.getComputedStyle(this[0]);
        const offset_width = this[0].offsetWidth;
        const border_left_width = parseFloat(styles.borderLeftWidth);
        const border_right_width = parseFloat(styles.borderRightWidth);
        const padding_left = parseFloat(styles.paddingLeft);
        const padding_right = parseFloat(styles.paddingRight);

        // return width
        return offset_width - border_left_width - border_right_width - padding_left - padding_right;

    }

    /**
     *  Wraps an HTML structure around each element in the set of matched elements.
     *
     *  > Note that if the method's argument is a selector then clones of the element described by the selector will be
     *  created and wrapped around each element in the set of matched elements except for the last one. The original item will
     *  be moved (not cloned) and wrapped around the last target.
     *
     *  @example
     *
     *  // always cache selectors
     *  // to avoid DOM scanning over and over again
     *  const element = $('#selector');
     *
     *  // wrap element in a div
     *  element.wrap('<div id="container"></div>');
     *
     *  // *exactly* the same thing as above
     *  element.wrap($('<div id="container"></div>'));
     *
     *  // using an existing element as the wrapper
     *  element.wrap($('#element-from-the-page'));
     *
     *  @param  {mixed} element     A string, a {@link ZebraJS} object or a DOM element in which to wrap around each element
     *                              in the set of matched elements.
     *
     *  @return {ZebraJS}   Returns the original set of matched elements.
     *
     *  @memberof   ZebraJS
     *  @alias      wrap
     *  @instance
     */
    $.fn.wrap = function(element) {

        // call the "_dom_insert" private method with these arguments
        this._dom_insert(element, 'wrap');

        // return the original element(s)
        return this;

    }


    // this is where we make the $ object available globally
    window.$ = window.jQuery = $;

})();