UNPKG

whiptail-js

Version:

A lightweight terminal-style dialog library for the web, inspired by the whiptail tool.

432 lines (365 loc) 13 kB
/*! * whiptail-js * A lightweight terminal-style dialog library for the web * * Copyright (c) 2025 Brian Walczak * Author: Brian Walczak (https://github.com/BrianWalczak) * Last Updated: 2025-08-31 */ if (typeof window.jQuery === 'undefined') { throw new Error("jQuery not found. Please include the jQuery library before whiptail-js."); } else { console.warn('whiptail-js has been successfully loaded.'); } class WhiptailJS { constructor(config) { this.config = config; this._render(); this._bindEvents(); if(config.focus) { this.focus(); } } get() { return this.$container; } focus() { if (this.$container) { this.$container.attr('tabindex', '-1'); this.$container.focus(); } } status() { return { item: this.$container.find('.items .item.focus')[0] || this.$container.find('.items .item.active')[0], footer: this.$container.find('.footer .item.focus')[0] || this.$container.find('.footer .item.active')[0] } } destroy() { this._unbindEvents(); this.$container.remove(); } return() { this.config.onClose(); } _render() { this.$container = $(this.config.selector); if (this.$container.length === 0) { throw new Error(`WhiptailJS: An error occurred, item with selector ${this.config.selector} not found.`); } this.$container.empty(); this.$container.addClass('whiptail-js container'); if(this.config?.width) { const width = this.config.width.toString(); this.$container.css('width', (width.includes('%') || width.includes('px')) ? width : `${width}px`); } if(this.config?.height) { const height = this.config.height.toString(); this.$container.css('height', (height.includes('%') || height.includes('px')) ? height : `${height}px`); } const header = $('<div class="header"><p></p></div>'); if(this.config?.title) { header.find('p').html(this.config.title); this.$container.append(header); } const content = $('<div class="content"></div>'); this.$container.append(content); const items = $('<div class="items"></div>'); if(this.config.text) { const $text = $(`<p></p>`); $text.css('margin-top', '0'); $text.css('margin-bottom', '1em'); $text.html(this.config.text); items.append($text); } if(this.config.items) { this.config.items.forEach((item) => { const $item = $(`<div class="item"></div>`); if(item.focus) $item.addClass('focus'); if(item.active) $item.addClass('active'); if(item.id) $item.attr('id', item.id); if(item.class) $item.addClass(item.class); $item.html(item.label); items.append($item); }); } const footer = $('<div class="footer"></div>'); this.config.footer.forEach((button) => { const $button = $(`<div class="item"></div>`); if(button.focus) $button.addClass('focus'); if(button.active) $button.addClass('active'); if(button.id) $button.attr('id', button.id); if(button.class) $button.addClass(button.class); $button.html(button.label); footer.append($button); }); content.append(items); content.append(footer); } _bindEvents() { const $items = this.$container.find('.items .item'); const $footerItems = this.$container.find('.footer .item'); let itemIndex = 0; let footerIndex = 0; let isFooter = false; function setFocus(i) { if (i < 0) i = 0; if (i >= $items.length) i = $items.length - 1; // Update current index and update focus class (to specified item) itemIndex = i; $items.removeClass('focus'); $items.eq(itemIndex).addClass('focus'); } function setFooterFocus(i) { if (i < 0) i = 0; if (i >= $footerItems.length) i = $footerItems.length - 1; // Update current index and update focus class (to specified footer item) footerIndex = i; $footerItems.removeClass('focus'); $footerItems.eq(footerIndex).addClass('focus'); } function enterFooter() { // Don't enter footer if there are no footer items if ($footerItems.length === 0) return; // Remove focus class, make active (blue color) $items.eq(itemIndex).removeClass('focus').addClass('active'); // Update current index and update focus class (to first item) isFooter = true; footerIndex = 0; $footerItems.removeClass('focus'); $footerItems.eq(footerIndex).addClass('focus'); } function exitFooter() { // Don't exit footer if there are no items to go back to if ($items.length === 0) return; // Remove active class, make focus (red color, in the selection mode) $items.eq(itemIndex).removeClass('active').addClass('focus'); // Remove focus class from all footer items $footerItems.removeClass('focus'); isFooter = false; } const selectItem = (item, btn) => { if(this.config.onSelect) { return this.config.onSelect(item[0], btn[0]); } }; const exitModal = () => { if(this.config.onClose) { return this.config.onClose(); } }; // Desktop support (arrow keys, enter/escape for selection) const keydownHandler = (event) => { if (event.key === 'ArrowUp') { event.preventDefault(); if (!isFooter) { setFocus(itemIndex - 1); } else if (footerIndex === 0) { // Exit footer if at leftmost (back to main items) exitFooter(); } else { setFooterFocus(footerIndex - 1); } } else if (event.key === 'ArrowDown') { event.preventDefault(); if (!isFooter) { setFocus(itemIndex + 1); } else { setFooterFocus(footerIndex + 1); } } else if (event.key === 'ArrowLeft' && isFooter) { event.preventDefault(); if (footerIndex === 0) { // Exit footer if at leftmost (back to main items) exitFooter(); } else { setFooterFocus(footerIndex - 1); } } else if (event.key === 'ArrowRight') { event.preventDefault(); if (!isFooter) { enterFooter(); } else { setFooterFocus(footerIndex + 1); } } else if (((event.key === 'Enter' || event.key === ' ') && isFooter) || event.key === 'Enter' && !isFooter) { event.preventDefault(); selectItem($items.eq(itemIndex), $footerItems.eq(footerIndex)); } else if (event.key === 'Escape') { event.preventDefault(); exitModal(); } }; $(document).on('keydown', keydownHandler); this.keydownHandler = keydownHandler; // Mobile support (tap to select item) this.$container.find('.item').on('click', function () { const isFooterItem = $(this).parent().hasClass('footer'); const index = $(this).parent().children('.item').index(this); if (isFooterItem) { // Focus on item in footer (execute afterwards) if (!isFooter) enterFooter(); setFooterFocus(index); selectItem($items.eq(itemIndex), $footerItems.eq(footerIndex)); } else { // Focus on item in list (don't select yet) if (isFooter) exitFooter(); setFocus(index); } }); } _unbindEvents() { $(document).off('keydown', this.keydownHandler); this.$container.find('.item').off('click'); } // -- Default Options -- // static msgbox(selector, text, ...args) { if(!selector || !text) throw new Error('Invalid arguments. Must provide a selector and text.'); let height = null; let width = null; let callback = null; let config = {}; for (let i = 0; i < args.length; i++) { if (typeof args[i] === 'number') { if (height === null) { height = args[i]; } else { width = args[i]; } } else if (typeof args[i] === 'function') { callback = args[i]; } else if (typeof args[i] === 'object') { config = args[i]; } } return new WhiptailJS({ text: text, height: height, width: width, selector: selector, items: [], footer: [ { label: '&lt;Ok&gt;', id: 'ok', focus: true } ], onSelect: callback, onClose: callback, ...config }); } static yesno(selector, text, ...args) { if(!selector || !text) throw new Error('Invalid arguments. Must provide a selector and text.'); let height = null; let width = null; let callback = null; let config = {}; for (let i = 0; i < args.length; i++) { if (typeof args[i] === 'number') { if (height === null) { height = args[i]; } else { width = args[i]; } } else if (typeof args[i] === 'function') { callback = args[i]; } else if (typeof args[i] === 'object') { config = args[i]; } } return new WhiptailJS({ text: text, height: height, width: width, selector: selector, items: [], footer: [ { label: '&lt;Yes&gt;', id: 'yes', focus: true }, { label: '&lt;No&gt;', id: 'no' } ], onSelect: callback, onClose: callback, ...config }); } static infobox(selector, text, ...args) { if(!selector || !text) throw new Error('Invalid arguments. Must provide a selector and text.'); let height = null; let width = null; let callback = null; let config = {}; for (let i = 0; i < args.length; i++) { if (typeof args[i] === 'number') { if (height === null) { height = args[i]; } else { width = args[i]; } } else if (typeof args[i] === 'function') { callback = args[i]; } else if (typeof args[i] === 'object') { config = args[i]; } } const instance = new WhiptailJS({ text: text, height: height, width: width, selector: selector, items: [], footer: [], onSelect: callback, onClose: callback, ...config }); instance.return(); // force callback return instance; } // inputbox not compatible // passwordbox not compatible static textbox(...args) { if(!args[1]) throw new Error('Invalid arguments. Must provide a valid file location.'); try { $.ajax({ url: args[1], dataType: 'text', success: function (response) { args[1] = response; }, error: function () { throw new Error('Invalid arguments. Must provide a valid file location.'); } }); } catch (e) { throw new Error('Invalid arguments. Must provide a valid file location.'); } return WhiptailJS.msgbox(...args); } static menu(selector, text, ...args) { if(!selector || !text) throw new Error('Invalid arguments. Must provide a selector and text.'); let height = null; let width = null; let items = null; let callback = null; let config = {}; for (let i = 0; i < args.length; i++) { if (typeof args[i] === 'number') { if (height === null) { height = args[i]; } else { width = args[i]; } } else if (typeof args[i] === 'function') { callback = args[i]; } else if (typeof args[i] === 'object') { if (Array.isArray(args[i])) { items = args[i]; } else { config = args[i]; } } } return new WhiptailJS({ text: text, height: height, width: width, selector: selector, items: items, footer: [ { label: '&lt;Ok&gt;', id: 'ok' } ], onSelect: callback, onClose: callback, ...config }); } // checklist not compatible // radiolist not compatible // gauge not compatible } window.WhiptailJS = WhiptailJS;