Bootstrap Modal Builder
A simple JavaScript solution for building Bootstrap modals on the fly.

Demo
Fiddle (ESM build): https://jsfiddle.net/c1uLypkg/
Fiddle (UMD build): https://jsfiddle.net/u5ah2dxq/

Repository
https://github.com/dday9/bootstrap-modal-extension

Usage
Code:
alert('E lorem ipsum dolor, etc...', { title: 'Hello World' });
confirm('Are you sure?', { title: 'Confirm' });
prompt('What\'s my age again?', { title: 'Age', type: 'number' });
Source Code
Code:
/* Utilities */
export class DOMBuilder {

  static create(tag, options = {}) {
    const element = document.createElement(tag);

    if (options.class) {
      if (Array.isArray(options.class)) {
        element.classList.add(...options.class);
      } else {
        element.className = options.class;
      }
    }

    if (options.attrs) {
      for (const [key, value] of Object.entries(options.attrs)) {
        if (value !== undefined && value !== null) {
          element.setAttribute(key, value);
        }
      }
    }

    if (options.dataset) {
      for (const [key, value] of Object.entries(options.dataset)) {
        if (value !== undefined && value !== null) {
          element.dataset[key] = value;
        }
      }
    }

    if (options.text !== undefined) {
      element.textContent = options.text;
    }

    if (Array.isArray(options.children)) {
      for (const child of options.children) {
        if (child) {
          element.append(child);
        }
      }
    }

    return element;
  }

}

export class IdGenerator {
	static #counter = 0;

	static next(prefix = 'id') {
		if (typeof crypto !== 'undefined' && crypto.randomUUID) {
			return `${prefix}-${crypto.randomUUID()}`;
		}

		this.#counter++;
		const base36Timestamp = Date.now().toString(36);
		const base36Counter = this.#counter.toString(36);
		return `${prefix}-${base36Timestamp}-${base36Counter}`;
	}
}

export class Utilities {

  static BOOTSTRAP_EVENT_SHOWN = 'shown.bs.modal';
  static BOOTSTRAP_EVENT_HIDDEN = 'hidden.bs.modal';

  static buildModal(options = {}) {
    return {
      dialog: {
        content: {
          header: options.header,
          body: options.body,
          footer: options.footer
        }
      },
      static: options.static === true
    };
  }

  static createModal(modalOptions) {
    const element = Modal.createElement(modalOptions);
    document.body.append(element);

    const modalInstance = new bootstrap.Modal(element);
    element.addEventListener(
      Utilities.BOOTSTRAP_EVENT_HIDDEN,
      () => document.body.removeChild(element),
      { once: true }
    );

    modalInstance.show();

    return { element, modalInstance };
  }

}

/* header */
export class ModalHeader {

  static BOOTSTRAP_CLASS = 'modal-header';

  static createElement(options) {

    if (!options?.title && options?.close === false) {
      return null;
    }

    const children = [];

    if (options?.title) {
      children.push(ModalTitle.createElement(options.title));
    }

    if (options?.close !== false) {
      children.push(CloseButton.createElement(options.close));
    }

    return DOMBuilder.create('div', {
      class: ModalHeader.BOOTSTRAP_CLASS,
      children
    });

  }

}

export class CloseButton {

  static BOOTSTRAP_CLASS = 'btn-close';
  static DEFAULT_ARIA_LABEL = 'Close';

  static createElement(options) {

    let ariaLabel = CloseButton.DEFAULT_ARIA_LABEL;

    if (typeof options === 'string') {
      ariaLabel = options.toString();
    }

    return DOMBuilder.create('button', {
      class: CloseButton.BOOTSTRAP_CLASS,
      attrs: {
        type: 'button',
        'aria-label': ariaLabel
      },
      dataset: {
        bsDismiss: 'modal'
      }
    });

  }

}

export class ModalTitle {

  static BOOTSTRAP_CLASS = 'modal-title';

  static createElement(options) {

    let title = '';

    if (typeof options === 'string') {
      title = options.toString();
    } else if (options?.title) {
      title = options.title;
    }

    return DOMBuilder.create('h5', {
      class: ModalTitle.BOOTSTRAP_CLASS,
      text: title
    });

  }

}

/* body */
export class ModalBody {

  static BOOTSTRAP_CLASS = 'modal-body';

  static createElement(options) {

    let text = '';

    if (typeof options === 'string') {
      text = options.toString();
    } else if (typeof options?.text === 'string') {
      text = options.text.toString();
    }

    const children = [];

    if (text.length > 0) {
      children.push(
        DOMBuilder.create('p', { text })
      );
    }

    if (options?.prompt) {
      children.push(
        ModalForm.createElement(options.prompt)
      );
    }

    return DOMBuilder.create('div', {
      class: ModalBody.BOOTSTRAP_CLASS,
      children
    });

  }

}

export class ModalForm {

  static GLOBAL_ATTRIBUTES = Object.freeze(new Set([
		'accept'
		, 'accept-charset'
		, 'action'
		, 'autocapitalize'
		, 'autocomplete'
		, 'enctype'
    , 'id'
		, 'method'
		, 'name'
		, 'novalidate'
		, 'rel'
		, 'target'
	, ]));

  static createElement(options) {
    const element = document.createElement('form');

    if (options?.input) {
      const input = ModalInput.createElement(options.input);
      element.appendChild(input);
    }

    ModalForm.applyGlobalAttributes(element, options);
    if (!element.id) {
      element.id = IdGenerator.next('modal-form');
    }

    if (options?.submit && typeof options.submit === 'function') {
      element.addEventListener('submit', options.submit);
    }

    return element;
  }

  static applyGlobalAttributes(element, options) {
    if (!element || !options) {
      return;
    }

    for (let globalAttribute of ModalForm.GLOBAL_ATTRIBUTES) {
      const attributeValue = options[globalAttribute];
      if (attributeValue !== undefined && attributeValue !== null) {
        element.setAttribute(globalAttribute, attributeValue);
      }
    }
  }

}

export class ModalInput {
  
  static BOOTSTRAP_CLASS = 'form-control';
  static DEFAULT_TYPE = 'text';
  static INPUT_TYPES = Object.freeze(new Set(['color', 'date', 'datetime-local', 'email', 'file', 'month', 'number', 'password', 'range', 'search', 'tel', 'text', 'time', 'url', 'week']));
  static VALIDATION_ENUMS = Object.freeze(new Set(['required', 'minlength', 'maxlength', 'min', 'max', 'step', 'pattern']));
  static GLOBAL_ATTRIBUTES = Object.freeze([
      'accesskey'
      , 'autocapitalize'
      , 'autocorrect'
      , 'contenteditable'
      , 'dir'
      , 'disabled'
      , 'draggable'
      , 'enterkeyhint'
      , 'exportparts'
      , 'id'
      , 'inert'
      , 'inputmode'
      , 'is'
      , 'itemid'
      , 'itemprop'
      , 'itemref'
      , 'itemscope'
      , 'itemtype'
      , 'lang'
      , 'name'
      , 'nonce'
      , 'part'
      , 'placeholder'
      , 'popover'
      , 'role'
      , 'slot'
      , 'spellcheck'
      , 'style'
      , 'tabindex'
      , 'title'
      , 'translate'
      , 'virtualkeyboardpolicy'
      , 'writingsuggestions'
    ]);

  static createElement(options) {
    if (options?.type === 'range') {
      return ModalInputRange.createElement(options);
    }

    let elementType = ModalInput.DEFAULT_TYPE;
    if (options?.type && ModalInput.INPUT_TYPES.has(options.type)) {
      elementType = options.type;
    } else if (options?.type) {
      console.warn(`Defaulting the input type to ${ModalInput.DEFAULT_TYPE} because "${options.type}" is not valid.`);
    }

    const element = document.createElement('input');
    element.setAttribute('type', elementType);
    
    if (options?.class) {
      element.className = options.class;
    } else {
      element.classList.add(ModalInput.BOOTSTRAP_CLASS);
    }

    if (options.validation) {
      ModalInput.applyValidation(element, options.validation);
    }
    ModalInput.applyGlobalAttributes(element, options);

    return element;
  }

  static applyGlobalAttributes(element, options) {
    if (!element || !options) {
      return;
    }

    for (let globalAttribute of ModalInput.GLOBAL_ATTRIBUTES) {
      const attributeValue = options[globalAttribute];
      if (attributeValue !== undefined && attributeValue !== null) {
        element.setAttribute(globalAttribute, attributeValue);
      }
    }
  }

  static applyValidation(element, options) {
    if (!element || !options) {
      return;
    }

    for (let validationEnum of ModalInput.VALIDATION_ENUMS) {
      const optionValue = options[validationEnum];

      if (optionValue !== undefined && optionValue !== null) {
        const optionValueType = typeof optionValue;
        if (
            (validationEnum === 'pattern' && optionValueType !== 'string')
            || (validationEnum === 'required' && optionValueType !== 'boolean')
            || (validationEnum !== 'pattern' && validationEnum !== 'required' && optionValueType !== 'number')
          ) {
          console.warn(`You may see unexpected results because the ${validationEnum} validation is not the right type.`);
        }
        element.setAttribute(validationEnum, optionValue);
      }
    }
  }
}

export class ModalInputRange {

  static BOOTSTRAP_CLASS = 'form-range';

  static createElement(options) {
    const element = document.createElement('input');
    element.setAttribute('type', 'range');
    
    if (options?.class) {
      element.className = options.class;
    } else {
      element.classList.add(ModalInputRange.BOOTSTRAP_CLASS);
    }
    if (options.validation) {
      ModalInput.applyValidation(element, options.validation);
    }
    ModalInput.applyGlobalAttributes(element, options);

    return element;
  }

}

/* footer */
export class ModalFooter {

  static BOOTSTRAP_CLASS = 'modal-footer';

  static createElement(options) {

    if (!options?.ok && !options?.cancel) {
      return null;
    }

    const children = [];

    if (options.ok) {

      const button = OkButton.createElement(options.ok);

      if (options.form && typeof options.form === 'string') {
        ActionButton.applyForm(button, options.ok, options.form);
      }

      children.push(button);
    }

    if (options.cancel) {

      const button = CancelButton.createElement(options.cancel);

      if (options.form && typeof options.form === 'string') {
        ActionButton.applyForm(button, options.cancel, options.form);
      }

      children.push(button);
    }

    return DOMBuilder.create('div', {
      class: ModalFooter.BOOTSTRAP_CLASS,
      children
    });

  }

}

export class ActionButton {

  static BOOTSTRAP_CLASS = 'btn';
  static BUTTON_TYPES = Object.freeze(new Set(['button', 'reset', 'submit']));

  static createElement(options) {
    const element = document.createElement('button');

    if (options?.text) {
      element.textContent = options.text;
    }

    return element;
  }

  static applyDataDismiss(element) {
    if (!element) {
      return;
    }

    element.dataset.bsDismiss = 'modal';
  }

  static applyForm(element, options, formSelector) {
    if (!element || !formSelector) {
      return;
    }

    let type = element.getAttribute('type');

    if (typeof options === 'object' && options !== null) {
      type = options.type;
    }

    if (type === 'submit' || type === 'reset') {
      element.setAttribute('form', formSelector);
    }
  }

  static applyClassName(element, options, bootstrapClass) {
    if (!element) {
      return;
    }

    if (options?.class && typeof options.class === 'string') {
      element.className = options.class;
    } else {
      element.classList.add(ActionButton.BOOTSTRAP_CLASS, bootstrapClass);
    }
  }

  static applyType(element, options, defaultType) {
    if (!element) {
      return;
    }

    if (options?.type && ActionButton.BUTTON_TYPES.has(options.type)) {
      element.setAttribute('type', options.type);
    } else {
      element.setAttribute('type', defaultType);
    }
  }

  static applyText(element, options) {
    if (!element) {
      return;
    }

    if ((options?.text && typeof options.text === 'string') || typeof options === 'string') {
      element.textContent = options?.text ?? options;
    }
  }

}

export class OkButton {

  static BOOTSTRAP_CLASS = 'btn-primary';
  static DEFAULT_TYPE = 'submit';

  static createElement(options) {
    const button = ActionButton.createElement(options);
    OkButton.applyStyles(button, options);
    return button;
  }

  static applyStyles(element, options) {
    if (!element) {
      return;
    }

    ActionButton.applyClassName(element, options, OkButton.BOOTSTRAP_CLASS);
    ActionButton.applyType(element, options, OkButton.DEFAULT_TYPE);

    if (options?.dismissOnClick === true) {
      ActionButton.applyDataDismiss(element);
    }

    ActionButton.applyText(element, options);
  }

}

export class CancelButton {

  static BOOTSTRAP_CLASS = 'btn-secondary';
  static DEFAULT_TYPE = 'button';

  static createElement(options) {
    const button = ActionButton.createElement(options);
    CancelButton.applyStyles(button, options);
    return button;
  }

  static applyStyles(element, options) {
    if (!element) {
      return;
    }

    ActionButton.applyClassName(element, options, CancelButton.BOOTSTRAP_CLASS);
    ActionButton.applyType(element, options, CancelButton.DEFAULT_TYPE);

    if (options?.dismissOnClick !== false) {
      ActionButton.applyDataDismiss(element);
    }

    ActionButton.applyText(element, options);
  }

}

/* wrappers */
export class Modal {

  static BOOTSTRAP_CLASS = 'modal';
  static DEFAULT_FADE = 'fade';
  static DEFAULT_TAB_INDEX = -1;

  static createElement(options) {

    const classes = [Modal.BOOTSTRAP_CLASS];

    if (options?.fade !== false) {
      classes.push(Modal.DEFAULT_FADE);
    }

    const dataset = {};

    if (options?.static === true) {
      dataset.bsBackdrop = 'static';
      dataset.bsKeyboard = 'false';
    }

    const dialog = ModalDialog.createElement(options?.dialog ?? {});

    return DOMBuilder.create('div', {
      class: classes,
      attrs: {
        tabindex: options?.tabIndex ?? Modal.DEFAULT_TAB_INDEX,
        id: options?.id ?? IdGenerator.next('bs-modal')
      },
      dataset,
      children: [dialog]
    });

  }

}

export class ModalDialog {

  static BOOTSTRAP_CLASS = 'modal-dialog';
  static SIZE_ENUM = Object.freeze(new Set(['sm', 'lg', 'xl']));

  static createElement(options) {

    const classes = [ModalDialog.BOOTSTRAP_CLASS];

    if (options?.scrollable !== false) {
      classes.push('modal-dialog-scrollable');
    }

    if (options?.verticallyCentered !== false) {
      classes.push('modal-dialog-centered');
    }

    if (options?.size && ModalDialog.SIZE_ENUM.has(options.size)) {
      classes.push(`modal-${options.size}`);
    }

    if (options?.fullscreen === true) {
      classes.push('modal-fullscreen');
    }

    const content = ModalContent.createElement(options?.content ?? {});

    return DOMBuilder.create('div', {
      class: classes,
      children: [content]
    });

  }

}

export class ModalContent {

  static BOOTSTRAP_CLASS = 'modal-content';

  static createElement(options) {

    const children = [];

    const header = ModalHeader.createElement(options?.header);

    if (header) {
      children.push(header);
    }

    children.push(
      ModalBody.createElement(options?.body)
    );

    const footer = ModalFooter.createElement(options?.footer);

    if (footer) {
      children.push(footer);
    }

    return DOMBuilder.create('div', {
      class: ModalContent.BOOTSTRAP_CLASS,
      children
    });

  }

}

/* prebuilt defaults */
export function alert(message, options = {}) {
  const modalOptions = Utilities.buildModal({
    header: {
      title: options.title ?? window.location.hostname,
      close: true
    },
    body: {
      text: message ?? ''
    },
    footer: false,
    static: true
  });

  Utilities.createModal(modalOptions);
}

export function confirm(message, options = {}) {
  let resolveFn;

  const promise = new Promise((resolve) => {
    resolveFn = resolve;
  });

  let confirmed = false;
  const formId = IdGenerator.next('confirm-form');
  const modalOptions = Utilities.buildModal({
    header: {
      title: options.title ?? window.location.hostname,
      close: true
    },
    body: {
      text: message ?? '',
      prompt: {
        id: formId,
        submit: e => {
          e.preventDefault();

          confirmed = true;
          modalInstance.hide();
        }
      }
    },
    footer: {
      ok: 'Yes',
      cancel: 'No',
      form: formId
    },
    static: true
  });

  const { element, modalInstance } = Utilities.createModal(modalOptions);
  element.addEventListener(
    Utilities.BOOTSTRAP_EVENT_HIDDEN,
    () => {
      resolveFn(confirmed);
    },
    { once: true }
  );

  return promise;
}

export function prompt(message, options = {}) {
  let resolveFn;
  let rejectFn;
  const promise = new Promise((resolve, reject) => {
    resolveFn = resolve;
    rejectFn = reject;
  });

  let submitted = false;
  let valueEntered = null;
  const formId = IdGenerator.next('prompt-form');
  const modalOptions = Utilities.buildModal({
    header: {
      title: options.title ?? window.location.hostname,
      close: true
    },
    body: {
      text: message ?? '',
      prompt: {
        id: formId,
        submit: e => {
          e.preventDefault();

          submitted = true;
          const input = element.querySelector(`#${formId} input`);
          valueEntered = input?.value;

          modalInstance.hide();
        },
        input: {
          type: options.type,
          validation: { required: true }
        }
      }
    },
    footer: {
      ok: 'Submit',
      cancel: 'Cancel',
      form: formId
    },
    static: true
  });

  const { element, modalInstance } = Utilities.createModal(modalOptions);
  element.addEventListener(
    Utilities.BOOTSTRAP_EVENT_SHOWN,
    () => {
      const input = element.querySelector(`#${formId} input`);
      input?.focus();
    },
    { once: true }
  );

  element.addEventListener(
    Utilities.BOOTSTRAP_EVENT_HIDDEN,
    () => {
      if (submitted) {
        resolveFn(valueEntered);
      } else {
        rejectFn(false);
      }
    },
    { once: true }
  );

  return promise;
}
Screenshots
Alert:


Confirm:


Prompt: