bootpopup.js

/**
 * Bootpopup instance returned by `bootpopup(options)` when a new popup is created.
 *
 * See https://github.com/vseryakov/bootpopup for more documentation.
 *
 */

 /* global bootstrap */

/**
 * Configuration object for a single element in a Bootpopup form/layout.
 *
 * “Special element properties” are parsed by Bootpopup to create DOM, apply Bootstrap
 * classes, add labels/groups, input-group addons, validation feedback, etc.
 *
 * @typedef {Object} BootpopupElement
 *
 * @property {string} [class]   Classes for the main element. If empty/omitted, `form-control` is used.
 * @property {string} [text]  Sets element's `textContent`.
 * @property {string} [html]   HTML to be parsed into DOM and appended (after sanitizing, if available).
 * @property {boolean} [autofocus]  Focus this element when the popup is shown.
 * @property {boolean} [nosanitize]  Skip sanitizer for `label` or `html` (if sanitizer is installed).
 * @property {boolean} [floating]   Adds `form-floating` to the group to enable floating labels.
 * @property {boolean} [checked]   When `true`, makes checkbox/radio selected.
 * @property {boolean} [switch]   When `true`, converts a checkbox into a toggle switch style.
 * @property {boolean} [inline]   When `true`, adds `form-check-inline` (checkbox/radio inline style).
 * @property {boolean} [reverse]   When `true`, adds `form-check-reverse` (checkbox style).
 * @property {string} [input_label]   Checkbox/radio specific label instead of the element's main label.
 * @property {string} [class_input_btn]   Converts checkbox into a button style. Must be one of Bootstrap `btn-*` classes.
 * @property {string} [class_check]   Adds a custom class to the checkbox/radio input element.
 * @property {string} [class_label]   Extra classes for the label element (added to default `form-label`).
 * @property {Object<string, any>} [attrs_label]   Attributes for the label element.
 * @property {number|string} [size_label]  Column width for the label (Bootstrap grid sizing).
 * @property {number|string} [size_input]  Column width for the input (Bootstrap grid sizing).
 * @property {string} [class_group]  Classes for the group wrapper div.
 *   Example: `"row my-3 py-1 border-bottom"`.
 * @property {Object<string, any>} [attrs_group] Attributes for the group wrapper div.
 *   Example: `{ id: "group1" }`.
 * @property {string} [class_prefix]  Class for a prefix span inserted as the first element in the group.
 * @property {string} [text_prefix]  Text for a prefix span inserted as the first element in the group.
 * @property {string} [class_suffix]  Class for a suffix element inserted as the last element in the group.
 * @property {string} [text_suffix]  Text for a suffix element inserted as the last element in the group.
 * @property {string} [text_valid]  Adds a “valid feedback” div (used with `validate()`).
 * @property {string} [text_invalid]  Adds an “invalid feedback” div (used with `validate()`).
 * @property {string} [class_append]   Appends a span to the element (mostly for non-input entries) with this class.
 * @property {string} [text_append]   Appends a span to the element (mostly for non-input entries) with this text.
 * @property {string} [text_input_button]   Adds a button tied to the input to perform an action.
 *   The button gets `data-formid` and `data-inputid` attributes.
 * @property {string} [class_input_button]   Button style class for the input button.
 * @property {string} [class_input_group]   Class for the input-group wrapper when using input buttons / dropdowns.
 * @property {Object<string, any>} [attrs_input_button]  Attributes for the input button (often includes `{ click: (ev) => {} }`).
 * @property {Array<string|{name:string, value:any}>} [list_input_button]  Adds a dropdown button with options; selected value is placed into an input.
 * @property {Array<string|{name:string, value:any}>} [list_input_tags]  Same as `list_input_button`, but also adds selected values as tags in the list.
 * @property {string} [class_list_button]  Class for the dropdown list button.
 * @property {string} [class_input_menu]  Class for the dropdown menu.
 */

/**
 * Bootpopup creation options.
 *
 * @typedef {Object} BootpopupOptions
 *
 * @property {Array<BootpopupElement>} [content=[]] Content of the dialog box.
 * @property {Array<BootpopupElement>} [footer=[]] Content inside the modal footer (simple elements only).
 * @property {string} [title=document.title] Title of the dialog box.
 * @property {boolean} [show_close=true] Show or hide the close button in the title.
 * @property {boolean} [show_header=true] Show or hide the dialog header with title.
 * @property {boolean} [show_footer=true] Show or hide the dialog footer with buttons.
 * @property {boolean} [keyboard=true] If false, disable closing the modal with keyboard.
 * @property {boolean|string} [backdrop=true] If false, disable modal backdrop; can be `static` as well.
 * @property {boolean} [scroll=true] Apply `modal-dialog-scrollable` if true.
 * @property {boolean} [center=false] Apply `modal-dialog-centered` if true.
 * @property {boolean} [horizontal=false] Enable/disable horizontal layout in the form element.
 * @property {("sm"|"lg"|"xl"|"")} [size=""] Size of the modal window.
 * @property {string} [size_label="col-sm-4"] Classes applied to labels in the form.
 * @property {string} [size_input="col-sm-8"] Classes applied to inputs wrapper in the form.
 * @property {("close"|"ok"|"cancel"|"yes"|"no")} [onsubmit="close"] Default action when form is submitted (overridden by `submit` callback).
 * @property {Array<"close"|"ok"|"cancel"|"yes"|"no">} [buttons=["close"]] Buttons shown in the dialog footer.
 * @property {Function} [before=function(){}] Called before the window is shown, after being created. `(popup)`.
 * @property {Function} [dismiss=function(){}] Called when the window is dismissed. `(data)`.
 * @property {Function} [submit=function(){}] Called when the form is submitted. Return `false` to cancel. `(data)`.
 * @property {Function} [close=function(){}] Called when Close button is selected. `(data)`.
 * @property {Function} [ok=function(){}] Called when OK button is selected. `(data)`.
 * @property {Function} [cancel=function(){}] Called when Cancel button is selected. `(data)`.
 * @property {Function} [yes=function(){}] Called when Yes button is selected. `(data)`.
 * @property {Function} [no=function(){}] Called when No button is selected. `(data)`.
 * @property {Function} [complete=function(){}] Always called when the dialog box has completed. `(data)`.
 * @property {boolean} [alert=false] If true, adds an alert element to be shown by `showAlert`.
 * @property {boolean} [info=false] If true, adds an info element to be shown by `showAlert(..., {type:"info"})`.
 * @property {boolean} [autofocus=true] If true, focus the first input element when shown.
 * @property {boolean} [empty=false] If true, return all input values even if empty; default returns only non-empty values.
 * @property {function(string):HTMLElement[]|null} [sanitizer=null] Called when rendering HTML content/labels; must return a list of HTMLElements to append.
 * @property {Object<string,string>|null} [tabs=null] Map of `{tabId: label, ...}` to show `nav-tabs`; content items can set `tab_id`.
 * @property {Object} [self] Context for callback functions; default is the popup object.
 * @property {boolean} [debug=false] Log input values in the console.
 * @property {string} [class_modal="modal fade"] Modal root class.
 * @property {string} [class_dialog="modal-dialog"] Modal dialog class.
 * @property {string} [class_title="modal-title"] Modal title class.
 * @property {string} [class_content="modal-content"] Modal content class.
 * @property {string} [class_body="modal-body"] Modal body class.
 * @property {string} [class_header="modal-header"] Modal header class.
 * @property {string} [class_footer="modal-footer"] Modal footer class.
 * @property {string} [class_options="options flex-grow-1 text-start"] Wrapper class for footer content.
 * @property {string} [class_alert="alert alert-danger fade show"] Class for danger alerts shown by `showAlert`.
 * @property {string} [class_info="alert alert-info fade show"] Class for info alerts shown by `showAlert`.
 * @property {string} [class_form=""] Class for the form wrapper div.
 * @property {string} [class_buttons="btn"] Base class for all buttons.
 * @property {string} [class_button="btn-outline-secondary"] Default style class for buttons (appended to `class_buttons`).
 * @property {string} [class_ok="btn-primary"] Style class for OK button.
 * @property {string} [class_yes="btn-primary"] Style class for Yes button.
 * @property {string} [class_no="btn-secondary"] Style class for No button.
 * @property {string} [class_cancel="btn-outline-secondary"] Style class for Cancel button.
 * @property {string} [class_close="btn-outline-secondary"] Style class for Close button.
 * @property {string} [class_tabs="nav nav-tabs mb-4"] Class for tab nav.
 * @property {string} [class_tablink="nav-link"] Class for tab links.
 * @property {string} [class_tabcontent="tab-content"] Class for tab content container.
 * @property {string} [class_group="row mb-3"] Wrapper class for each content element group.
 * @property {string} [class_label=""] Extra class appended to form labels.
 * @property {string} [class_row="row"] Class used for content type `row`.
 * @property {string} [class_col="col-auto"] Class used for columns in `row` content items.
 * @property {string} [class_suffix="form-text text-muted text-end"] Class used for content added to a group (suffix/help text).
 * @property {string} [class_input_button="btn btn-outline-secondary"] Default class for `text_input_button`.
 * @property {string} [class_list_button="btn btn-outline-secondary dropdown-toggle"] Default class for `list_input_button` button.
 * @property {string} [class_input_menu="dropdown-menu bg-light"] Default class for `list_input_button` dropdown.
 * @property {string} [list_input_mh="25vh"] Default max height of the dropdown in `list_input_button`.
 */


import {
    $, $all, $attr, $elem, $empty, $on, $parse,
    isFunction, isObject, isString
    } from "./index"

import { sanitizer } from "./lib"


const inputs = [
    "text", "color", "url", "password", "hidden", "file", "number",
    "email", "reset", "date", "time", "checkbox", "radio", "datetime-local",
    "week", "tel", "search", "range", "month", "image", "button"
];

const escapeMap = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;', '`': '&#x60;' };

/**
 * Create an instance of Bootpoup class and show it
 * @param {BootpopupOptions} ...args
 * @returns {Bootpopup}
 *
 * ### DOM elements
 *
 * All the following Bootpopup properties are native HTML elements:
 *
 * - `modal` - entire window, including the fade background. You can use this property in the same way as
 *  described in [Bootstrap Modals Usage](https://getbootstrap.com/docs/javascript/#modals-usage)
 * - `dialog` - entire window, without the background
 * - `content` - content of the dialog
 * - `header` - header of the dialog
 * - `body` - body of the dialog
 * - `form` - main form in the dialog, inside the `body`
 * - `footer` - footer of the dialog
 *
 * ### Buttons
 *
 * - `btn_close` - close button (if present)
 * - `btn_ok` - OK button (if present)
 * - `btn_cancel` - cancel button (if present)
 * - `btn_yes` - yes button (if present)
 * - `btn_no` - no button (if present)
 *
 * Any ad-hoc button will be added in the form `btn_Label`.
 *
 * NOTE: All actions by default close the popup window, a callback must return `null` in order to keep the popup window
 * visible, this is useful when validating the input. Manually closing the popup is done via the `close` method.
 *
 * ### About the **buttons** option:
 *
 * In addition to default buttons `ok, close, cancel, yes, no` a button can be defined in ad-hoc manner with any label as long as the
 * button callback is named the same, for example
 *
 * ```javascript
 *      bootpopup({
 *        ...
 *        buttons: ["cancel", "Order"],
 *        Order: (data) => {
 *            ...
 *        }
 *      }
 * ```
 *
 * Clicking on the `Order` button will call the Order callback.
 *
 * Customizing default button labels can be done via `text_NAME` properties, for example
 *
 * ```javascript
 * bootpopup({
 *   buttons:["ok","cancel"],
 *   text_ok: "Submit",
 * })
 * ```
 *
 * Now ok will be shown as Submit. With ad-hoc labeling this is not very useful but still can be used for default buttons.
 *
 * ### About the **content** option:
 *
 * #### The biggest flexibility of Bootpopup is the `content` option. The content is wrapped by a form allowing to create complex forms very quickly.
 * When you are submitting data via a dialog box, Bootpopup will grab all that data and deliver to you through the callbacks.
 * `content` is an array of objects and each object is represented as an entry of the form. For example, if you
 *    have the following object:

 *    ```javascript
 *    { p: {class: "bold", text: "Insert data:"}}
 *    ```

 *    This will add a `<p></p>` tag to the form. The options of `p` (`{class: "bold", text: "Insert data:"}`) are HTML
 *    attributes passed to the HTML tag. There is a special attribute for `text` which is defined as the inner text of
 *    the HTML tag. So, this example is equivalent to the following HTML:

 *    ```html
 *    <p class="bold">Insert data:</p>
 *    ```

 * #### But it is when it comes to adding inputs that things become easy. Look at this example:

 *    ```javascript
 *    { input: {type: "text", label: "Title", name: "title", placeholder: "Description" }}
 *    ```

 *    This will create an `input` element with the attributes `type: "text", label: "Title", name: "title", placeholder: "Description"`.
 *    Note there is also a special attribute `label`. This attribute is used by Bootpopup to create a label for the input form entry.
 *    The above example is equivalent to the following HTML:

 *    ```html
 *    <div class="form-group mb-3">
 *      <label for="title" class="col-form-label col-sm-4">Title</label>
 *      <div class="col-sm-10">
 *        <input label="Title" name="title" id="title" placeholder="Description" class="form-control" type="text">
 *      </div>
 *    </div>
 *    ```
 * #### In order to make it even simpler, there are shortcuts for most common input types:
 *    `"text", "color", "url", "password", "hidden", "file", "number",
 *     "email", "reset", "date", "time", "checkbox", "radio", "datetime-local",
 *     "week", "tel", "search", "range", "month", "image", "button"`.

 *    The previous example can be simply written as:

 *    ```javascript
 *    { text: {label: "Title", name: "title", placeholder: "Description" }}
 *    ```

 * #### `select`, `checkboxes` and `radios` have a special attribute named `options`. You can specify a list of options to be shown in 2 formats:
 *      - an object where the key is used as value by the input and the value is the text displayed
 *      - a list of objects with { label:, value:, name: } properties

 *    ```javascript
 *    { select: { label: "Select", name: "select", options: { a:"A", b:"B", c:"C" }}}
 *    { radios: { label: "Radios", name: "radios", options: { a:"A", b:"B", c:"C" }}}
 *    { checkboxes: { label: "Checkboxes", options: [ { name: "c1", label: "A" }, { name: "c2", label: "C", value: 2 } ]}}
 *    ```

 *  `select` with attribute `multiple` is also supported. For multi-select to make an item selected the value must match or property
 *   selected must be true in case of objects: `{ name: .., value:.., selected: true }`

 * #### Another useful feature is the ability to support functions directly as an attribute. Take the following `button` example:

 *    ```javascript
 *    { button: {name: "button", value: "Open image", class: "btn btn-info", click: (event) => {
 *        console.log(event);
 *        bootpopup.alert("Hi there");
 *    }}}
 *    ```
 *    This will create a `onclick` event for the button. The reference for the object is passed as argument to the function.

 * #### You can also insert HTML strings directly. Instead of writing an JS object, write the HTML:

 *    ```javascript
 *    '<p class="lead">Popup dialog boxes for Bootstrap.</p>'
 *    ```

 * #### List of special element properties:
 *  - `class` - to customize the style of the element just provide all classes in the `class` property, if empty `form-control` is set
 *  - `text` - set element's `textContent`
 *  - `html` - parse and add DOM elements after running thru sanitizer
 *  - `autofocus` - make this element focused on show
 *  - `nosanitize` - skip sanitizer for labels or html property if installed
 *  - `floating` - add `form-floating` to the group to make labels floating
 *  - `checked` - true to make checkbox/radios selected
 *  - `switch` - true to convert a checkbox into a toggle style
 *  - `inline` - true to add `form-check-inline` to a checkbox style
 *  - `reverse` - true to add `form-check-reverse` to checkbox style
 *  - `input_label` - checkbox/radio specific label instead of the element's label
 *  - `class_input_btn` - convert checkbox into a button style, must be one of btn- classes
 *  - `class_check` - add custom class to checkbox/radio element
 *  - `class_label` - to customize the label style, added to the default `form-label`
 *  - `attrs_label` - attributes for the label element, an object
 *  - `size_label` - set column width for the label
 *  - `size_input` - set column width for the input
 *  - `class_group` - to customize the group, example: `{ class_group: "row my-3 py-1 border-bottom" }` to set border and gaps for an element
 *  - `attrs_group` - attributes for the group div, an object, example: `{ attrs_group: { id: "group1" } }`
 *  - `class_prefix` and/or `text_prefix` - add as a first span to the group with a span with class/text
 *  - `class_suffix` and/or `text_suffix` - make it the last div in the group with class/text
 *  - `text_valid`, `text_invalid` - add divs for valid or invalid feedback, to be used with `validate` method
 *  - `class_append` and/or `text_append` - append a span to an element, mostly for non-input entries
 *  - `text_input_button` - add a button the element to perform an action on it, `data-formid` and `data-inputid` are set on the button with form and the input elem
 * ent ids for easy access in the callbacks, use `class_input_button` to change the button style, use `class_input_group` to change the input group style
 *  - `attrs_input_button` - attributes to the input button, an object, usually `{ click: (ev) => {} }`
 *  - `list_input_button` or `list_input_tags` - add a dropdown button with options to select and place into an input, use `class_list_button` to change the button
 * style, use `class_input_group` to change the input group style, use `class_input_menu` to change the dropdown menu style, the list can be an Array of strings or ob
 * jects `{ name:, value: }`. The `list_input_tags` adds a selected value in the list.
 *
 * ### Alert:
 *
 *   ```javascript
 *   bootpopup.alert("Hi there");
 *   ```
 *
 * ### Confirm:
 *
 *   ```javascript
 *   bootpopup.confirm("Do you confirm this message?", (yes) => {
 *     alert(yes);
 *   });
 *   ```
 *
 * ### Prompt:
 *
 *   ```javascript
 *   bootpopup.prompt("Name", (value) => {
 *     alert(value);
 *   });
 *   ```
 *
 * ### Customized prompt:
 *
 *   ```javascript
 *   bootpopup({
 *      title: "Add image",
 *       content: [
 *           '<p class="lead">Add an image</p>',
 *           { p: {text: "Insert image info here:"}},
 *           { input: {type: "text", label: "Title", name: "title", placeholder: "Description for image"}},
 *           { input: {type: "text", label: "Link", name: "link", placeholder: "Hyperlink for image"}}],
 *       buttons: ["ok", "cancel"],
 *       cancel: () => { alert("Cancel") },
 *       ok: (data, list) => { console.log(data, list) },
 *       complete: () => { alert("complete") },
 *   });
 *
 * ### Validation:
 *
 * ```javascript
 * var popup = bootpopup({
 *     title: "Add details",
 *     alert: 1,
 *     content: [
 *         { text: { label: "Name", name: "title", placeholder: "your name"}},
 *         { number: { label: "Age", name: "age", placeholder: "your age"}}],
 *     buttons: ["ok", "cancel"],
 *     text_ok: "Verify",
 *     ok: (data) => {
 *         if (!data.name) return popup.showAlert("name is required")
 *     },
 * });
 * ```
 *
 * See more [Examples](https://vseryakov.github.io/bootpopup/index.html).
 */

export function bootpopup(...args)
{
    return new Bootpopup(...args);
}

/**
 * Bootpopup class
 * @param {BootpopupOptions} ..args
 * @class
 */

class Bootpopup {

   /** @property {string} formid Randomly generated HTML id of the form. */
    formid = "bpf" + String(Math.random()).substr(2);
    controller = new AbortController();

    /** @property {Object} options Options used to create the window. */
    options = {
        self: null,
        id: "",
        title: document.title,
        debug: false,
        show_close: true,
        show_header: true,
        show_footer: true,
        size: "",
        size_label: "col-sm-4",
        size_input: "col-sm-8",
        content: [],
        footer: [],
        onsubmit: "close",
        buttons: ["close"],
        attrs_modal: null,
        class_h: "",
        class_modal: "modal fade",
        class_dialog: "modal-dialog",
        class_title: "modal-title",
        class_content: "modal-content",
        class_body: "modal-body",
        class_header: "modal-header",
        class_footer: "modal-footer",
        class_group: "mb-3",
        class_options: "options flex-grow-1 text-start",
        class_alert: "alert alert-danger fade show",
        class_info: "alert alert-info fade show",
        class_form: "",
        class_label: "",
        class_row: "",
        class_col: "",
        class_suffix: "form-text text-muted text-end",
        class_buttons: "btn",
        class_button: "btn-outline-secondary",
        class_ok: "btn-primary",
        class_yes: "btn-primary",
        class_no: "btn-secondary",
        class_cancel: "btn-outline-secondary",
        class_close: "btn-outline-secondary",

        class_tabs: "nav nav-tabs mb-4",
        class_tablink: "nav-link",
        class_tabcontent: "tab-content",
        class_input_button: "btn btn-outline-secondary",
        class_list_button: "btn btn-outline-secondary dropdown-toggle",
        class_input_menu: "dropdown-menu bg-light",
        list_input_mh: "25vh",
        text_ok: "OK",
        text_yes: "Yes",
        text_no: "No",
        text_cancel: "Cancel",
        text_close: "Close",
        center: false,
        scroll: false,
        horizontal: true,
        alert: false,
        info: false,
        backdrop: true,
        keyboard: true,
        autofocus: true,
        empty: false,
        data: "",
        tabs: "",
        tab: "",
        inputs: ["input", "textarea", "select"],

        sanitizer: (html) => (sanitizer(html, true)),

        before: function() {},
        dismiss: function() {},
        close: function() {},
        ok: function() {},
        cancel: function() {},
        yes: function() {},
        no: function() {},
        show: function() {},
        shown: function() {},
        showtab: function() {},
        complete: function() {},
        submit: function(e) {
            this.callback(this.options.onsubmit, e);
            e.preventDefault();
        },
    }

    constructor(...args) {
        this.addOptions(...bootpopup.plugins, ...args);
        this.create();
        this.show();
    }

    /**
     * @description Create the window and add it to the DOM, but do not show it.
     * @returns {Bootpopup} This instance.
     */
    create() {
        this.eventOptions = { signal: this.controller.signal };

        // Option for modal dialog size
        var class_dialog = this.options.class_dialog;
        if (["sm", "lg", "xl", "fullscreen"].includes(this.options.size)) class_dialog += " modal-" + this.options.size;
        if (this.options.center) class_dialog += " modal-dialog-centered";
        if (this.options.scroll) class_dialog += " modal-dialog-scrollable";

        // Create HTML elements for modal dialog
        var modalOpts = { class: this.options.class_modal, id: this.options.id || "", tabindex: "-1", "aria-labelledby": "a" + this.formid, "aria-hidden": true };
        if (this.options.backdrop !== true) modalOpts["data-bs-backdrop"] = typeof this.options.backdrop == "string" ? this.options.backdrop : false;
        if (!this.options.keyboard) modalOpts["data-bs-keyboard"] = false;
        for (const p in this.options.attrs_modal) modalOpts[p] = this.options.attrs_modal[p];

        this.modal = $elem('div', modalOpts, this.eventOptions);
        this.dialog = $elem('div', { class: class_dialog, role: "document" });
        this.content = $elem('div', { class: this.options.class_content + " " + this.options.class_h });
        this.dialog.append(this.content);
        this.modal.append(this.dialog);

        // Header
        if (this.options.show_header && this.options.title) {
            this.header = $elem('div', { class: this.options.class_header });
            const title = $elem('h5', { class: this.options.class_title, id: "a" + this.formid });
            title.append(...this.sanitize(this.options.title));
            this.header.append(title);

            if (this.options.show_close) {
                const close = $elem('button', { type: "button", class: "btn-close", "data-bs-dismiss": "modal", "aria-label": "Close" });
                this.header.append(close);
            }
            this.content.append(this.header);
        }

        // Body
        var class_form = this.options.class_form;
        if (!class_form && this.options.horizontal) class_form = "form-horizontal";
        this.body = $elem('div', { class: this.options.class_body });
        this.form = $elem("form", { id: this.formid, class: class_form, role: "form", submit: (e) => (this.options.submit(e)) });
        this.body.append(this.form);
        this.content.append(this.body);

        if (this.options.alert) {
            this.alert = $elem("div");
            this.form.append(this.alert);
        }
        if (this.options.info) {
            this.info = $elem("div");
            this.form.append(this.info);
        }

        // Tabs and panels
        if (this.options.tabs) {
            const toggle = /nav-pills/.test(this.options.class_tabs) ? "pill" : "tab";
            this.tabs = $elem("div", { class: this.options.class_tabs, role: "tablist" });
            this.form.append(this.tabs);
            this.tabContent = $elem("div", { class: this.options.class_tabcontent });
            this.form.append(this.tabContent);
            this.tabPanels = {};

            for (const p in this.options.tabs) {
                // Skip tabs with no elements
                if (!this.options.content.some((o) => {
                    for (const k in o) {
                        for (const l in o[k]) {
                            if (l == "tab_id" && p == o[k][l]) return 1;
                        }
                    }
                    return 0
                })) continue;
                const active = this.options.tab ? this.options.tab == p : !Object.keys(this.tabPanels).length;
                const tid = this.formid + "-tab" + p;

                const a = $elem("a", {
                    class: this.options.class_tablink + (active ? " active" : ""),
                    "data-bs-toggle": toggle,
                    id: tid + "0",
                    href: "#" + tid,
                    role: "tab",
                    "aria-controls": tid,
                    "aria-selected": false,
                    "data-callback": p,
                    click: (event) => { this.options.showtab(event.target.dataset.callback, event) },
                    text: this.options.tabs[p],
                }, this.eventOptions);
                this.tabs.append(a);

                this.tabPanels[p] = $elem("div", {
                    class: "tab-pane fade" + (active ? " show active": ""),
                    id: tid,
                    role: "tabpanel", "aria-labelledby":
                    tid + "0"
                });
                this.tabContent.append(this.tabPanels[p]);
            }
        }

        // Iterate over entries
        for (const c in this.options.content) {
            const entry = this.options.content[c];
            switch (typeof entry) {
            case "string":
                // HTML string
                this.form.append(...this.sanitize(entry));
                break;

            case "object":
                for (const type in entry) {
                    processEntry(this, type, entry[type]);
                }
                break;
            }
        }

        // Footer
        this.footer = $elem('div', { class: this.options.class_footer });
        if (this.options.show_footer) {
            this.content.append(this.footer);
        }

        for (const i in this.options.footer) {
            const entry = this.options.footer[i];
            let div, html, elem;
            switch (typeof entry) {
            case "string":
                this.footer.append(...this.sanitize(entry));
                break;

            case "object":
                div = $elem('div', { class: this.options.class_options });
                this.footer.append(div)
                for (const type in entry) {
                    const opts = typeof entry[type] == "string" ? { text: entry[type] } : entry[type], attrs = {};
                    for (const p in opts) {
                        if (p == "html") {
                            html = opts.nosanitize ? $parse(opts[p]) : this.sanitize(opts[p]);
                        } else
                        if (!/^(type|[0-9]+)$|^(class|text|icon|size)_/.test(p)) {
                            attrs[p] = opts[p];
                        }
                    }
                    elem = $elem(opts.type || type, attrs, this.eventOptions)
                    if (html) elem.append(...html);
                    div.append(elem);
                }
                break;
            }
        }

        // Buttons
        for (const i in this.options.buttons) {
            var name = this.options.buttons[i];
            if (!name) continue;
            const btn = $elem("button", {
                type: "button",
                class: `${this.options.class_buttons} ${this.options["class_" + name] || this.options.class_button}`,
                "data-callback": name,
                "data-formid": "#" + this.formid,
                click: (event) => { this.callback(event.target.dataset.callback, event) }
            }, this.eventOptions);

            btn.append(...this.sanitize(this.options["text_" + name] || name));
            if (this.options["icon_" + name]) {
                btn.append($elem("i", { class: this.options["icon_" + name] }));
            }
            this["btn_" + name] = btn;
            this.footer.append(btn);
        }

        // Setup events for dismiss and complete
        $on(this.modal, 'show.bs.modal', (e) => {
            this.options.show.call(this.options.self, e, this);
        }, this.eventOptions);

        $on(this.modal, 'shown.bs.modal', (e) => {
            if (this.options.autofocus) {
                var focus = this.autofocus ||
                    Array.from($all("input,select,textarea", this.form)).
                    find(el => !(el.readOnly||el.disabled||el.type=='hidden'));
                if (focus) focus.focus();
            }
            this.options.shown.call(this.options.self || this, e, this);
        }, this.eventOptions);

        $on(this.modal, 'hide.bs.modal', (e) => {
            if (document.activeElement instanceof HTMLElement) {
                document.activeElement.blur();
            }
            e.bootpopupButton = this._callback;
            this.options.dismiss.call(this.options.self, e, this);
        }, this.eventOptions);

        $on(this.modal, 'hidden.bs.modal', (e) => {
            e.bootpopupButton = this._callback;
            this.options.complete.call(this.options.self, e, this);
            this.modal.remove();
            bootstrap.Modal.getInstance(this.modal)?.dispose();
            delete this.options.data;
            this.controller.abort();
        }, this.eventOptions);
    }

    /**
    * @description Show the window and call the `before` callback.
    * @returns {Bootpopup} This instance.
    */
    show() {
        // Call before event
        this.options.before.call(this, this);

        if (isObject(this.options.data)) {
            var xdata = this.xdata = Alpine.reactive(this.options.data);
            Alpine.addScopeToNode(this.modal, xdata);
            Alpine.initTree(this.modal);
            Alpine.onElRemoved(this.modal, () => {
                delete this.modal._x_dataStack;
            });
        }

        document.body.append(this.modal);

        // Fire the modal window
        bootstrap.Modal.getOrCreateInstance(this.modal).show();
    }

    /**
    * @param {string} msg Message to display.
    * @param {Object} [options] Alert options.
    * @param {("info")} [options.type] Use `"info"` to show information message (requires `info: true`).
    * @param {boolean} [options.dismiss] If true, the message must be closed manually.
    * @param {number} [options.delay] Auto-hide after this delay (ms).
    * @returns {Bootpopup} This instance.
    */
    showAlert(text, opts) {
        const type = opts?.type || "alert", element = this[type];
        if (!element) return;
        if (text?.message) text = text.message;
        if (typeof text != "string") return;
        const alert = $elem(`div`, { class: this.options['class_' + type], role: "alert", text });
        if (opts?.dismiss) {
            alert.classList.add("alert-dismissible");
            alert.append($elem(`button`, { type: "button", class: "btn-close", 'data-bs-dismiss': "alert", 'aria-label': "Close" }));
        } else {
            setTimeout(() => { $empty(element) }, opts?.delay || this.delay || 10000);
        }
        $empty(element).append(alert);
        if (this.options.scroll) {
            element.scrollIntoView();
        }
        return null;
    }

    /**
     * @description Run `checkValidity()` and return the result.
     * @returns {boolean} Validity result.
     */
    validate() {
        this.form.classList.add('was-validated')
        return this.form.checkValidity();
    }

    sanitize(str) {
        return !str ? [] : this.options.sanitizer(str);
    }


    /**
    * @param {string} str String to escape.
    * @returns {string} Escaped string where `&<>'"` are converted into HTML entities.
    */
    escape(str) {
        if (typeof str != "string") return str;
        return str.replace(/([&<>'"`])/g, (_, n) => (escapeMap[n] || n));
    }

    /**
    * @description Return form input values from all inputs.
    * @returns {{obj: Object<string, any>, list: any[]}} Data as `{ obj: {}, list: [] }`.
    */
    data() {
        var inputs = [...this.options.inputs, ...bootpopup.inputs];
        var d = { list: [], obj: {} }, e, n, v, l = $all(inputs.join(","), this.form);
        for (let i = 0; i < l.length; i++) {
            e = l[i];
            n = e.name || $attr(e, "name") || e.id || $attr("id");
            v = e.value;
            if (this.options.debug) console.log("bootpopup:", n, e.type, e.checked, v, e);
            if (!n || e.disabled) continue;
            if (/radio|checkbox/i.test(e.type) && !e.checked) v = undefined;
            if (v === undefined || v === "") {
                if (!this.options.empty) continue;
                v = "";
            }
            d.list.push({ name: n, value: v })
        }
        for (const v of d.list) d.obj[v.name] = v.value;
        if (this.options.debug) console.log("bootpopup:", this.options.inputs, d);
        return d;
    }

    /**
    * @description Call a callback for the given action and return its result.
    * @param {"dismiss"|"submit"|"close"|"ok"|"cancel"|"yes"|"no"} name Action name.
    * @returns {any} Callback return value.
    */
    callback(name, event) {
        if (this.options.debug) console.log("bootpopup:", name, event);
        var func = isFunction(this.options[name]);
        if (!func) return;
        this._callback = name;
        var a = this.data();
        var result = func.call(this.options.self || this, a.obj, a.list, event);
        if (result instanceof Promise) {
            result.then(resolved => {
                if (resolved !== null) {
                    bootstrap.Modal.getOrCreateInstance(this.modal).hide();
                }
            })
        } else {
            // Hide window if not prevented
            if (result !== null) {
                bootstrap.Modal.getOrCreateInstance(this.modal).hide();
            }
            return result;
        }
    }

    /**
    * @param {Object} options Add/merge options into current options.
    * @returns {Bootpopup} This instance.
    */
    addOptions(...args) {
        for (const opts of args) {
            for (const key in opts) {
                if (opts[key] !== undefined) {
                    // Chaining all callbacks together, not replacing
                    if (isFunction(this.options[key])) {
                        const _old = this.options[key], _n = opts[key];
                        this.options[key] = function(...args) {
                            if (isFunction(_old)) _old.apply(this, args);
                            return _n.apply(this, args);
                        }
                    } else {
                        this.options[key] = opts[key];
                    }
                }
            }
        }
        // Determine what is the best action if none is given
        if (this.options.onsubmit == "close") {
            if (this.options.buttons.includes("ok")) this.options.onsubmit = "ok"; else
            if (this.options.buttons.includes("yes")) this.options.onsubmit = "yes";
        }
        return this.options;
    }

    /**
    * @description Close popup window (performs the `close` action).
    * @returns {void}
    */
    close() {
        return this.callback("close")
    }

}

bootpopup.plugins = [];
bootpopup.inputs = [];

/**
 * Shows an alert dialog box.
 * @Returns: instance of Bootpopup window
 * @param {string} message - message of the alert
 * @param {function} callback - `(function)()` callback when the alert is dismissed
 */
bootpopup.alert = function(text, callback)
{
    return new Bootpopup({
        show_header: false,
        content: [{ div: { text } }],
        class_footer: "modal-footer justify-content-center",
        dismiss: callback,
    });
}

/**
 * Shows a confirm dialog box.
 * @Returns: instance of Bootpopup window
 *
 * @param {string} message - message to confirm
 * @param {function} callback - `(function)(answer)` callback when the confirm is answered. `answer` will be `true`
 * if the answer was yes and `false` if it was no. If dismissed, the default answer is no
 */
bootpopup.confirm = function(text, callback)
{
    var ok;
    return new Bootpopup({
        show_header: false,
        content: [{ div: { text } }],
        buttons: ["yes", "no"],
        class_footer: "modal-footer justify-content-center",
        yes: isFunction(callback) ? () => { callback(ok = true) } : null,
        dismiss: isFunction(callback) ? () => { if (!ok) callback(false) } : null,
    });
}

/**
* Shows a prompt dialog box, asking to input a single value.
* @Returns: instance of Bootpopup window
*
* @param {string} label - label of the value being asked
* @param {function} callback - `(function)(answer)` callback with the introduced data. This is only called when OK is pressed
*/
bootpopup.prompt = function(label, callback)
{
    var ok;
    return new Bootpopup({
        show_header: false,
        content: [{ input: { name: "value", label } }],
        buttons: ["ok", "cancel"],
        ok: isFunction(callback) ? (d) => { callback(ok = d.value || "") } : null,
        dismiss: isFunction(callback) ? () => { if (ok === undefined) callback() } : null,
    });
}

function addElement(self, entry)
{
    var { type, attrs, opts, parent, children, elem, group } = entry;

    if (!self.options.inputs.includes(type)) {
        self.options.inputs.push(type);
    }
    if (opts.class_append || opts.text_append) {
        const span = $elem("span", { class: opts.class_append, text: opts.text_append });
        elem.append(span);
    }
    if (opts.list_input_button || opts.list_input_tags) {
        if (attrs.value && opts.list_input_tags) {
            $attr(elem, 'value', attrs.value.split(/[,|]/).map(x => x.trim()).filter(x => x).join(', '));
        }
        group = $elem('div', { class: `input-group ${opts.class_input_group || ""}` });
        group.append(elem);
        elem = group;

        const button = $elem('button', {
            class: opts.class_list_button || self.options.class_list_button,
            type: "button",
            'data-bs-toggle': "dropdown",
            'aria-haspopup': "true",
            'aria-expanded': "false",
            text: opts.text_input_button,
        });
        elem.append(button);

        var menu = $elem('div', {
            class: opts.class_input_menu || self.options.class_input_menu,
            "-overflowY": "auto",
            "-maxHeight": opts.list_input_mh || self.options.list_input_mh
        });
        elem.append(menu);

        var list = opts.list_input_button || opts.list_input_tags || [];
        for (const l of list) {
            let n = l, v = self.escape(n);
            if (typeof n == "object") v = self.escape(n.value), n = self.escape(n.name);
            if (n == "-") {
                menu.appendTo($elem('div', { class: "dropdown-divider" }));
            } else
            if (opts.list_input_tags) {
                const a = $elem('a', {
                    class: "dropdown-item " + (opts.class_list_input_item || ""),
                    role: "button",
                    'data-attrid': '#' + attrs.id,
                    text: n,
                    click: (ev) => {
                        var el = $(ev.target.dataset.attrid);
                        var v = ev.target.textContent;
                        if (!el.value) {
                            el.value = v;
                        } else {
                            var l = el.value.split(/[,|]/).map(x => x.trim()).filter(x => x);
                            if (!l.includes(v)) l.push(v);
                            el.value = l.join(', ');
                        }
                    },
                }, self.eventOptions);
                menu.append(a);
            } else {
                const a = $elem('a', {
                    class: "dropdown-item " + (opts.class_list_input_item || ""),
                    role: "button",
                    'data-value': v || n,
                    'data-attrid': '#' + attrs.id,
                    text: n,
                    click: (ev) => {
                        $(ev.target.dataset.attrid).value = ev.target.dataset.value
                    },
                }, self.eventOptions);
                menu.append(a);
            }
        }
    } else
    if (opts.text_input_button) {
        group = $elem('div', { class: `input-group ${opts.class_input_group || ""}` });
        group.append(elem);
        elem = group;
        const bopts = {
            class: opts.class_input_button || self.options.class_input_button,
            type: "button",
            'data-formid': '#' + self.formid,
            'data-inputid': '#' + attrs.id,
            text: opts.text_input_button
        };
        for (const b in opts.attrs_input_button) bopts[b] = opts.attrs_input_button[b];
        const button = $elem('button', bopts);
        elem.append(button);
    }

    for (const k in children) elem.append(children[k]);
    var class_group = opts.class_group || self.options.class_group;
    var class_label = (opts.class_label || self.options.class_label) + " " + (attrs.value ? "active" : "");
    var gopts = { class: class_group, title: attrs.title };
    for (const p in opts.attrs_group) gopts[p] = opts.attrs_group[p];

    group = $elem('div', gopts)
    parent.append(group);

    if (opts.class_prefix || opts.text_prefix) {
        const div = $elem("span", { class: opts.class_prefix || "" });
        if (opts.text_prefix) div.append(...self.sanitize(opts.text_prefix));
        group.append(div);
    }
    if (opts.horizontal !== undefined ? opts.horizontal : self.options.horizontal) {
        group.classList.add("row");
        class_label = " col-form-label " + (opts.size_label || self.options.size_label) + " " + class_label;
        const lopts = { for: opts.for || attrs.id, class: class_label };
        for (const p in opts.attrs_label) lopts[p] = opts.attrs_label[p];
        const label = $elem("label", lopts);
        label.append(...self.sanitize(opts.label));

        const input = $elem('div', { class: opts.size_input || self.options.size_input });
        input.append(elem);
        group.append(label, input);
    } else {
        const lopts = { for: opts.for || attrs.id, class: "form-label " + class_label };
        for (const p in opts.attrs_label) lopts[p] = opts.attrs_label[p];
        const label = $elem("label", lopts);
        label.append(...self.sanitize(opts.label));

        if (opts.floating) {
            if (!opts.placeholder) $attr(elem, "placeholder", "");
            group.classList.add("form-floating");
            group.append(elem);
            if (opts.label) group.append(label);
        } else {
            if (opts.label) group.append(label);
            group.append(elem);
        }
    }
    if (opts.text_valid) {
        group.append($elem("div", { class: "valid-feedback", text: opts.text_valid }));
    }
    if (opts.text_invalid) {
        group.append($elem("div", { class: "invalid-feedback", text: opts.text_invalid }));
    }
    if (opts.class_suffix || opts.text_suffix) {
        const div = $elem("div", { class: opts.class_suffix || self.options.class_suffix });
        if (opts.text_suffix) div.append(...self.sanitize(opts.text_suffix));
        group.append(div);
    }
    if (opts.autofocus) self.autofocus = elem;
}

function processEntry(self, type, entry)
{
    var parent = self.form, opts = {}, children = [], attrs = {}, label, elem, html;

    if (Array.isArray(entry)) {
        children = entry;
    } else
    if (typeof entry == "string") {
        opts.label = entry;
    } else {
        for (const p in entry) opts[p] = entry[p];
    }
    for (const p in opts) {
        if (p == "html") {
            html = opts.nosanitize ? $parse(opts[p]) : self.sanitize(opts[p]);
        } else
        if (!/^(tab_|attrs_|click_|list_|class_|text_|icon_|size_|label|for)/.test(p)) {
            attrs[p] = opts[p];
        }
    }

    // Create a random id for the input if none provided
    if (!attrs.id) attrs.id = "bpi" + String(Math.random()).substr(2);
    attrs["data-formid"] = "#" + self.formid;

    // Choose to the current tab content
    if (opts.tab_id && self.tabs) {
        parent = self.tabPanels[opts.tab_id] || parent;
    }

    // Check if type is a shortcut for input
    if (inputs.includes(type)) {
        attrs.type = type;
        type = "input";
    }

    switch (type) {
    case "button":
    case "submit":
    case "input":
    case "textarea":
        attrs.type = (attrs.type === undefined ? "text" : attrs.type);
        if (attrs.type == "hidden") {
            elem = $elem(type, attrs, self.eventOptions);
            parent.append(elem);
            break;
        }
        if (!attrs.class) attrs.class = self.options["class_" + attrs.type];

    case "select":
        if (type == "select" && attrs.options) {
            if (attrs.caption) {
                children.push($elem("option", { text: attrs.caption, value: "" }));
            }
            for (const j in attrs.options) {
                const option = {}, opt = attrs.options[j];
                if (typeof opt == "string") {
                    if (attrs.value === opt) option.selected = true;
                    option.text = self.escape(opt);
                    if (isString(j)) option.value = j;
                    children.push($elem("option", option));
                } else
                if (opt?.name) {
                    option.value = opt.value || "";
                    if (opt.selected || attrs.value === option.value) option.selected = true;
                    if (opt.label) option.label = opt.label;
                    if (typeof opt.disabled == "boolean") option.disabled = opt.disabled;
                    option.text = self.escape(opt.name);
                    children.push($elem("option", option));
                }
            }
            delete attrs.options;
            delete attrs.value;
        }

        // Special case for checkbox
        if (["radio", "checkbox"].includes(attrs.type) && !opts.raw) {
            if (attrs.checked === false || attrs.checked == 0) delete attrs.checked;
            label = $elem('label', {
                class: opts.class_input_btn || opts.class_input_label || "form-check-label",
                for: opts.for || attrs.id,
                text: opts.input_label || opts.label
            });
            let class_check = "form-check";
            if (opts.switch) class_check += " form-switch", attrs.role = "switch";
            if (opts.inline) class_check += " form-check-inline";
            if (opts.reverse) class_check += " form-check-reverse";
            if (opts.class_check) class_check += " " + opts.class_check;
            attrs.class = (opts.class_input_btn ? "btn-check " : "form-check-input ") + (attrs.class || "");
            elem = $elem('div', { class: class_check });
            elem.append($elem(type, attrs, self.eventOptions), label);

            if (opts.class_append || opts.text_append) {
                label.append($elem("span", { class: opts.class_append || "", text: opts.text_append }));
            }
            // Clear label to not add as header, it was added before
            if (!opts.input_label) delete opts.label;
        } else {
            if (["select", "range"].includes(attrs.type)) {
                attrs.class = `form-${attrs.type} ${attrs.class || ""}`;
            }
            attrs.class = attrs.class || "form-control";
            if (type == "textarea") {
                delete attrs.value;
                elem = $elem(type, attrs, self.eventOptions);
                if (opts.value) elem.append(opts.value);
            } else {
                elem = $elem(type, attrs, self.eventOptions);
            }
        }
        addElement(self, { type, attrs, opts, parent, children, elem });
        break;

    case "radios":
    case "checkboxes":
        elem = $elem("div", { class: opts.class_container });
        for (const i in attrs.options) {
            let o = attrs.options[i];
            if (!o) continue;
            if (isString(o)) o = { label: o };
            if (!o.value && type[0] == "r") o.value = i;
            if (o.checked === false || o.checked == 0) delete o.checked;
            const title = o.title;
            const label = $elem('label', { class: "form-check-label", for: attrs.id + "-" + i, text: o.label || o.name });
            o = Object.assign(o, {
                id: attrs.id + "-" + i,
                name: o.name || opts.name,
                class: `form-check-input ${o.class || ""}`,
                role: opts.switch && "switch",
                type: attrs.type || type[0] == "r" ? "radio" : "checkbox",
                label: undefined,
                title: undefined,
            });
            let c = "form-check";
            if (o.switch || opts.switch) c += " form-switch";
            if (o.inline || opts.inline) c += " form-check-inline";
            if (o.reverse || opts.reverse) c += " form-check-reverse";
            if (o.class_check || opts.class_check) c += " " + (o.class_check || opts.class_check);
            const div = $elem('div', { class: c, title: title });
            div.append($elem(`input`, o, self.eventOptions), label);
            children.push(div);
        }
        for (const p of ["switch", "inline", "reverse", "options", "value", "type"]) delete attrs[p];
            addElement(self, { type, attrs, opts, parent, children, elem });
        break;

    case "alert":
    case "success":
        self[type] = elem = $elem("div", attrs, self.eventOptions);
        parent.append(elem);
        break;

    case "row":
        var row = $elem("div", { class: opts.class_row || self.options.class_row || "row" });
        parent.append(row);
        for (const subEntry of children) {
            const col = $elem("div", { class: subEntry.class_col || self.options.class_col || "col-auto" });
            row.append(col);
            const oldParent = parent;
            parent = col;
            for (const type in subEntry) {
                processEntry(self, type, subEntry[type]);
            }
            parent = oldParent;
        }
        break;

    default:
        elem = $elem(type, attrs, self.eventOptions);
        if (html) {
            elem.append(...html);
        }
        if (opts.class_append || opts.text_append) {
            elem.append($elem("span", { class: opts.class_append || "", text: opts.text_append }));
        }
        if (opts.name && opts.label) {
            addElement(self, { type, attrs, opts, parent, children, elem });
        } else {
            parent.append(elem);
        }
    }
}