whiptail-js
Version:
A lightweight terminal-style dialog library for the web, inspired by the whiptail tool.
432 lines (365 loc) • 13 kB
JavaScript
/*!
* 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: '<Ok>', 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: '<Yes>', id: 'yes', focus: true },
{ label: '<No>', 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: '<Ok>', id: 'ok' }
],
onSelect: callback,
onClose: callback,
...config
});
}
// checklist not compatible
// radiolist not compatible
// gauge not compatible
}
window.WhiptailJS = WhiptailJS;