Dismissable Dialog for QuestJS

The default dialog in QuestJS is a nifty feature to receive and process input from the player using a variety of widgets. This library expands on this feature by allowing scripts to be added to each widget to process the input immediately upon player interaction. It also allows the dialog to be dismissed by clicking outside the borders of the dialog.

Current Code

The code can be found at https://github.com/cellarderecho/derecho-quest-libs/tree/main/dismissable-dialog, if I make any changes.

"use strict"

document.addEventListener('click', function(event) {
  var dialog = document.getElementById('dialog');
  if (io.dialogDismissable && event.target !== dialog && !dialog.contains(event.target)) {
    io.dialogCancel();
  }
});

io.dialogDismissable = false

io.dialog = function(data) {
  if (test.testing || settings.walkthroughMenuResponses.length > 0) {
    return
  }

  world.suppressEndTurn = true

  io.dialogWidgets = data.widgets
  io.dialogOkayScript = data.okayScript
  io.dialogCancelScript = data.cancelScript
  const diag = document.getElementById("dialog")
  diag.innerHTML = "";

  const heading = document.createElement('h3');
  heading.className = 'dialog-heading';
  heading.textContent = data.title;
  diag.appendChild(heading);

  if (data.desc) {
    const p = document.createElement('p');
    p.textContent = data.desc;
    diag.appendChild(p);
  }

  if (data.html) {
    diag.insertAdjacentHTML('beforeend', data.html);
  }

  for (const el of data.widgets) {
    diag.appendChild(io.setWidget(el));
  }

  if (!data.dismissable || !data.suppressCancel || !data.suppressOkay) {
    const hr = document.createElement('hr');
    diag.appendChild(hr);

    const p = document.createElement('p');
    if (!data.suppressCancel) {
      const cancel = document.createElement('input');
      cancel.type = 'button';
      cancel.value = 'Cancel';
      cancel.style.color = 'grey';
      cancel.style.float = 'right';
      cancel.addEventListener('click', io.dialogCancel);
      p.appendChild(cancel);
    }

    if (!data.dismissable || !data.suppressOkay) {
      const okay = document.createElement('input');
      okay.type = 'button';
      okay.value = 'Okay';
      okay.style.color = 'grey';
      okay.style.float = 'right';
      okay.addEventListener('click', io.dialogOkay);
      p.appendChild(okay);
    }
    diag.appendChild(p);
  }

  diag.style.width = '400px';
  diag.style.height = 'auto';
  diag.style.top = '80px';
  diag.style.position = 'fixed';
  diag.style.display = 'block';

  document.body.appendChild(diag);

  io.disable(3);

  if (data.dismissable) {
    const dismissScript = document.createElement('script');
    dismissScript.text = 'setTimeout(function() { io.dialogDismissable = true; }, 0);';
    diag.appendChild(dismissScript);
  }
}

io.setWidget = function(options) {
  let type = options.type;
  if (type === 'auto') {
    type = Object.keys(options.data).length > settings.widgetRadioMax ? 'dropdown' : 'radio';
  }

  const widgetDiv = document.createElement('div');
  widgetDiv.id = `dialog-div-${options.name}`;
  widgetDiv.className = 'widget';

  const h4 = document.createElement('h4');
  h4.textContent = options.title;
  widgetDiv.appendChild(h4);

  if (type === 'radio') {
    let value = (typeof options.value === 'string' && Object.keys(options.data).includes(options.value)) ? options.value : Object.keys(options.data)[0];

    const div = document.createElement('div');
    div.id = options.name;
    div.style.display = 'none';
    widgetDiv.appendChild(div);

    for (const key in options.data) {
      const input = document.createElement('input');
      input.type = 'radio';
      input.name = options.name;
      input.id = key;
      input.value = key;

      if (key === value) {
        input.checked = true;
      }
      if (options.hasOwnProperty('oninput')) {
        input.addEventListener('input', (event) => {
          io.onWidgetInput(options.name, event.target.value)
        });
      }
      
      widgetDiv.appendChild(input);

      const label = document.createElement('label');
      label.htmlFor = key;
      label.textContent = options.data[key];
      widgetDiv.appendChild(label);

      const br = document.createElement('br');
      widgetDiv.appendChild(br);
    }
  }

  else if (type === 'dropdown') {
    let value = (typeof options.value === 'string' && Object.keys(options.data).includes(options.value)) ? options.value : Object.keys(options.data)[0]

    const select = document.createElement('select');
    select.name = options.name;
    select.id = options.name;
    if (options.hasOwnProperty('oninput')) {
      select.addEventListener('change', (event) => {
        io.onWidgetInput(options.name, event.target.value)
      });
    }
    const br = document.createElement('br')
    select.appendChild(br)
    for (const key in options.data) {
      const option = document.createElement('option');
      option.value = key
      if (key === value){
        option.selected = "selected"
      }
      option.textContent = options.data[key]
      select.appendChild(option)
    }  
    widgetDiv.appendChild(select);
  }

  else if (type === 'dropdownPlus') {
    let index = options.data.findIndex(el => el.name === options.value);
    if (index === -1) index = 0

    const select = document.createElement('select');
    select.name = options.name;
    select.id = options.name;
    if (options.hasOwnProperty('oninput')) {
      select.addEventListener('change', (event) => {
        io.onWidgetInput(options.name, event.target.value)
      });
    }
    const br = document.createElement('br')
    select.appendChild(br)

    for (let i = 0; i < options.data.length; i++) {
      const  el = options.data[i]
      const option = document.createElement('option');
      option.value = el.name
      if (i === index){
        option.selected = "selected"

      }
      option.textContent = el.title
      select.appendChild(option)
    }

    widgetDiv.appendChild(select);

    const p = document.createElement('p')
    p.className = "dialog-text"
    p.id = `${options.name}-text`
    p.textContent = options.data[index].text

    widgetDiv.appendChild(p);
  }

  else if (type === 'checkbox') {
    const input = document.createElement('input');
    input.type = "checkbox"
    input.name = options.name;
    input.id = options.name;
    if (options.value) input.checked = true
    if (options.hasOwnProperty('oninput')) {
      input.addEventListener('input', (event) => {
        io.onWidgetInput(options.name, event.target.checked)
      });
    }
 
    widgetDiv.appendChild(input);

    const label = document.createElement('label')
    label.htmlFor = options.name;
    label.textContent = options.data;
    widgetDiv.appendChild(label);
  }

  else if (type === 'color' || type === 'colour') {
    const colorRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/
    let value = (typeof options.value === 'string' && options.value.match(colorRegex)) ? options.value : '#000000'
    const input = document.createElement('input');
    input.type = "color"
    input.name = options.name;
    input.id = options.name;
    input.value = value;
    if (options.hasOwnProperty('oninput')) {
      input.addEventListener('input', (event) => {
        io.onWidgetInput(options.name, event.target.value)
      });
    }
 
    widgetDiv.appendChild(input);

  }

  else if (type === 'range' || type === 'number') {
    const value = typeof options.value === 'number' ? options.value : 0
    const input = document.createElement('input');
    input.type = type
    input.name = options.name;
    input.id = options.name;
    input.value = value;
    if (options.min) input.min = options.min
    if (options.max) input.max = options.max
    if (options.step) input.step = options.step
    if (options.hasOwnProperty('oninput')) {
      input.addEventListener('input', (event) => {
        io.onWidgetInput(options.name, event.target.value)
      });
    }
 
    widgetDiv.appendChild(input);

  }

  else if (type === 'text' || type === 'password') {
    const value = typeof options.value === 'string' ? options.value : ''
    const input = document.createElement('input');
    input.type = type
    input.name = options.name;
    input.id = options.name;
    input.value = value;
    if (options.min) input.minlength = options.min
    if (options.max) input.maxlength = options.max
    if (options.pattern) input.pattern = options.pattern
    if (options.placeholder) input.placeholder = options.placeholder
    if (options.hasOwnProperty('oninput')) {
      input.addEventListener('blur', (event) => {
        io.onWidgetInput(options.name, event.target.value)
      });
    }

    widgetDiv.appendChild(input);

  }

  else if (type === 'file') {
    const value = typeof options.value === 'string' ? options.value : ''
    const input = document.createElement('input');
    input.type = type
    input.name = options.name;
    input.id = options.name;
    input.value = value;
    if (options.accept) input.accept = options.accept
    if (options.hasOwnProperty('oninput')) {
      input.addEventListener('input', (event) => {
        io.onWidgetInput(options.name, event.target.value)
      });
    }
 
    widgetDiv.appendChild(input);

  }

  if (options.comment) {
    const p = document.createElement('p');
    p.className = 'dialog-comment';
    p.textContent = options.comment;
    widgetDiv.appendChild(p);
  }

  return widgetDiv;
};

io.onWidgetInput = function(widgetName, value){
  const result = {}
  result[widgetName] = value
  io.dialogWidgets.find(widget => widget.name === widgetName).oninput(result)
}

io.htmlValue = function(options) {
  //log(options.name)
  // use type to cover the auto type
  const type = document.querySelector('#' + options.name).type

  let value
  
  if (options.type === 'dropdownPlus') {
    value = document.querySelector('#' + options.name).value
    options.checked = 0
    for (const el of options.data) {
      if (el.name === value) break
      options.checked++
    }
  }
  else if (type === 'select-one') {
    value = document.querySelector('#' + options.name).value
    options.checked = 0
    for (const key in options.data) {
      if (key === value) break
      options.checked++
    }
  }
  else if (type === 'checkbox') {
    value = document.querySelector('#' + options.name).checked
    options.checked = value
  }
  else if (type === 'text' || type === 'number' || type === 'password' || type === 'range' || type === 'color' || type === 'file') {
    value = document.querySelector('#' + options.name).value
    if (type === 'number') value = parseInt(value)
    options.checked = value
  }
  else {  // radio button has no type
    value = document.querySelector('input[name="' + options.name + '"]:checked').value
    options.checked = 0
    for (const key in options.data) {
      if (key === value) break
      options.checked++
    }
  }

  return value
}

io.dialogOkay = function() {
  const diag = document.getElementById("dialog")  
  diag.style.display = 'none'
  io.enable()
  io.dialogDismissable = false
  const results = {}
  for (const data of io.dialogWidgets) {
    results[data.name] = io.htmlValue(data)
  }
  //log(results)
  io.dialogOkayScript(results)
  world.endTurn(world.SUCCESS)
}

io.dialogCancel = function() {
  const diag = document.getElementById("dialog")  
  diag.style.display = 'none'
  io.enable()
  io.dialogDismissable = false
  if (io.dialogCancelScript) io.dialogCancelScript()
  world.endTurn(world.FAILED)
}

Parameters for io.dialog()

  • title - The title of the dialog
  • widgets - A list of widgets to display in the dialog
  • okayScript - A script to run when "Okay" is clicked. Returns a dictionary with each widget's current value.
  • cancelScript - A script to run when "Cancel" is clicked.
  • suppressOkay - A boolean to hide the "Okay" button. Only effective if dismissable is also set to true.
  • suppressCancel - A boolean to hide the "Cancel" button.
  • dismissable - A boolean to allow the dialog to be hidden by clicking outside its borders. The cancelScript is executed if this happens.

Widgets

There are two main changes that the dismissable dialog makes to the default QuestJS dialog widgets.

  1. The default widget value can be set with value. The type of the value must match the type of the data returned (e.g. a "range" widget returns a number, so it may only be provided a number in the value)
  2. The oninput script can be provided to be called whenever the player provides an input to the widget. This script may specify up to one parameter, which will be passed a dictionary with the widget name and the current value.
  3. A new file type widget is added.

Example

const onInputFunc = function (input){
      console.log(input)
    }
    
    const widgets = {
      title:'Test All Dialog Widgets',
      widgets:[
        { type: 'radio', title: 'Radio', name: 'radio', data: {radio1:'Option 1', radio2: 'Option 2'}, oninput: onInputFunc, value: 'radio2' },
        { type: 'dropdown', title: 'Dropdown', name: 'dropdown', data: {dropdown1:'Option 1', dropdown2: 'Option 2'}, oninput: onInputFunc, value: 'dropdown2' },
        { type:'dropdownPlus', title:'DropdownPlus', name:'dropdownPlus', lines:4, data:[
          {name:'dropdownPlus1', title:'Option 1', text:'Text for option 1'},
          {name:'dropdownPlus2', title:'Option 2', text:'Text for option 2'},
        ], oninput: onInputFunc, value: 'dropdownPlus2'},
        { type:'checkbox', title:'Checkbox', name:'checkbox', data:'Checked?', oninput: onInputFunc, value: true},
        { type: 'color', title: 'Color', name: 'color', oninput: onInputFunc, value: '#77767b' },
        { type:'range', title:'Range', name:'range', data:'Range?', min: 0, max: 10, step: 2, oninput: onInputFunc, value: 6},
        { type:'number', title:'Number', name:'number', data:'Number?', min: 0, max: 10, oninput: onInputFunc, value: 3},
        { type:'text', title:'Text', name:'text', min: 3, max: 10, oninput: onInputFunc, value: 'text'},
        { type:'password', title:'Password', name:'password', min: 3, max: 10, oninput: onInputFunc, placeholder: 'password'},
        { type:'file', title: 'File', name: 'file', accept: ".txt,.json", oninput: onInputFunc}
      ],
      okayScript:function(results) {
        console.log(results)
      },
      cancelScript:function(results) {
        console.log(results)
      },
      suppressOkay: true,
      suppressCancel: true,
      dismissable: true
    }
    
    io.dialog(widgets)

This topic is now closed. Topics are closed after 14 days of inactivity.

Support

Forums