Source: dom-tools.js

/**
 * @license MIT
 * Copyright © 2025 Steve Butler (henspace.com)
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the “Software”),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */

/**
 * @module hcje/domTools
 * @description
 * Module containing buttons, dialogs and other features for manipulating the DOM. It also includes a cutdown Markdown
 * parser to allow Markdown to be used in dialog messages.
 */

import * as utils from './utils.js';
import * as device from './device.js';


/** 
 * Markdown replacements.
 * @type {Array<{re: RegExp, rep: string}>}
 * @see {@link module:hcje/domTools~parseMarkdown}
 * @private
 */ 
const MARKDOWN_REPS = [
  /* special character replacements */ 
  {re: /\r/g, rep: ''},
  {re:/(?:&(?!(\w{2,5}|#\d{2,5}|#x[0-9A-F]{2,5});))/gi , rep: '&amp;'},
  {re: /</g, rep: '&lt;'},
  {re: />/g, rep: '&gt;'},
  {re: /"/g, rep: '&quot;'},
  {re: /'/g, rep: '&apos;'},
  
  /* block divisions */
  {re: /^[\+\-*] (.*)$/gm, rep: '<uli>$1</uli>'},
  {re: /<\/uli>\n<uli>/g, rep: '</uli><uli>'},
  {re: /\n((?:<uli>.*?<\/uli>)+)\n/gs, rep: '\n<ul>$1</ul>\n'},

  {re: /^[\d]+ (.*)$/gm, rep: '<oli>$1</oli>'},
  {re: /<\/oli>\n<oli>/g, rep: '</oli><oli>'},
  {re: /\n((?:<oli>.*?<\/oli>)+)\n/gs, rep: '\n<ol>$1</ol>\n'},

  {re: /[ou]li>/g, rep: 'li>'},

  {re: /^# (.*?)#?$/gm, rep: '<h1>$1</h1>'},
  {re: /^## (.*?)(?:##)?$/gm, rep: '<h2>$1</h2>'},
  {re: /^### (.*?)(?:###)?$/gm, rep: '<h3>$1</h3>'},
  {re: /^#### (.*?)(?:####)?$/gm, rep: '<h4>$1</h4>'},
  
  /* remove interblock divisions */
  {re: /<\/(h\d|ul|ol)>\n*<(h\d|ul|ol)>/g, rep: '<\/$1><$2>'},

  /* add paragraph blocks */
  {re: /([^>])\n{2,}([^<])/g, rep: '$1</p><p>$2'},
  {re: />\n+([^<])/g, rep: '><p>$1'},
  {re: /([^>])\n+</g, rep: '$1</p><'},
  {re: /^\s*([^<])/, rep: '<p>$1'},
  {re: /([^>])\s*$/g, rep: '$1</p>'},
  
  /* simple span replacements */
  {re: /&lt;br&gt;/gmi, rep: '<br>'},
  {re: /`(.+?)`/gm, rep: '<code>$1</code>'},
  {re: /\*\*([^\n]+?)\*\*/gm, rep: '<strong>$1</strong>'},
  {re: /\*([^\n]+?)\*/gm, rep: '<em>$1</em>'},
  {re: /!\[([\w ,;.&-]+?)\]\(((?:https:\/\/|\.\/)[^\s?]+?)(?: *&quot;([\w ]*?)&quot;)?\)/gm, rep: '<img alt = "$1" src="$2" title="$3" />'},
  {re: /\[([\w ,;.&-]+?)\]\(((?:https:\/\/|\.\/)[^\s?]+?)(?: *&quot;([\w ]*?)&quot;)?\)/gm, rep: '<a href="$2" target="_blank" title="$3">$1</a>'},
]



/**
 * Parse markdown. This is a very limited set of markdown. All HTML tags in the markdown are replaced by character
 * entities.
 *
 * + Character entities are supported.
 * + Four levels of headings are supported but only using the `## abc` format.
 * + Images are supported but with the same limitations as links.
 * + Links are supported, but only for `https://` links and without query strings.
 * + Only asterisks are supported for strong and emphasis formatting. E.g. `**bold**`. Underscores are not.
 * + Ordered and unordered lists are supported but only to one level of indentation.
 * + Inline code is supported, but not code blocks. To escape the backtick, use a character entity of &#60;
 *
 * @param {string} markdown
 * @returns {string}
 */
export function parseMarkdown(markdown) {
  let html = markdown;
  for (const replacement of MARKDOWN_REPS) {
    html = html.replace(replacement.re, replacement.rep);
  }
  return html;
}

/**
 * Create an element as a child of another. This is just a convenience method to simplify the creation of an element,
 * the addition of a class name, and attachment to a parent element.
 * @param {module:hcje/domTools~ElementWrapper|Element} parentElement - Parent to which to attach element as child.
 * @param {string} tagName - DOM tag name.
 * @param {string} className - Class name to add to element.
 * @returns {Element}
 */
export function createChild(parentElement, tagName, className) {
  const child = document.createElement(tagName);
  if (className) {
    child.className = className;
  }
  parentElement.appendChild(child);
  return child;
}

/**
 * Create a divider, which is essentially a labelled horizontal rule.
 * @param {Object} config
 * @param {Element} parentElement - Parent to which the rule should be added as a child.
 * @param {string} label - The label to apply.
 * @param {string} alignment - The label position: 'left', 'center' or 'right'.
 * @returns {HTMLDivElement}
 */
export function createDivider(config) {
  const container = document.createElement('div');
  const baseClass = 'hcje-divider';
  container.className = baseClass;
  let align = config.alignment ? config.alignment.toUpperCase() : 'LEFT';
  if (align === 'CENTER' || align === 'RIGHT') {
    createChild(container, 'hr', `${baseClass}__hr`)
  }
  const label = createChild(container, 'span', `${baseClass}__label`);
  label.innerText = config.label;
  if (align === 'CENTER' || align === 'LEFT') {
    createChild(container, 'hr', `${baseClass}__hr`)
  }
  return container;
}



/**
 * @callback ButtonListener
 * @param {Event} event - Triggering event
 * @param {boolean} isDown - True if toggle button is in down state.
 */

/**
 * @typedef {Object} ButtonConfig
 * @property {Element} parentElement - The buttons's parent element.
 * @property {string} url - Path to the image used on the button.
 * @property {string} [urlOn] - Path to the image used on the button if in it's on or  down state. If provided, this is
 *   treated as a toggle button
 * @property {boolean} [on] - Should the button start in the on position. Only applicable to a toggle button.
 * @property {string} label - The button label.
 * @property {string} labelOn - The label for toggle buttons if on.
 * @property {string} className - Additional class name applied to the button's container.
 * @property {module:hcje/domTools~ButtonListener} onClick - Listener called on the click event.
 * @property {module:hcje/domTools~ButtonRepeatInterval} interval - Set the button to repeat with the specified interval in ms. If the button is
 * set as a toggle button, this is ignored. Note that if a repeat interval is provided, a 
 * button repeater is used for handling events. Instead of just using 
 * the **click** event, the repeater will use [Pointer events]{@link https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events}
 * or [Touch events]{@link https://developer.mozilla.org/en-US/docs/Web/API/Touch_events}. If these events are
 * preferred over the simple **click** event, you can provide an empty object for **repeatInterval** property or set
 * its delay property to zero.
 */

/**
 * Wrapper for an [HTMLElement]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement} which allow objects
 * inherited from this class to be used in place of an
 * [HTMLElement]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement} in some limited applications.
 */ 
export class ElementWrapper {
  /** The base element.
   * @type{HTMLElement} */ 
  #element;

  /**
   * Construct the wrapper. If a tag name is provided, a new element is created, otherwise the provided element is 
   * wrapped.
   * @param {string|HTMLElement} elementType - Tag name or the actual element to be wrapped.
   */ 
  constructor(elementType) {
    this.#element = elementType instanceof Element ? elementType : document.createElement(elementType);
  }

  /**
   * The wrapped element. This is protected and is only intended for use by the domTools module.
   * @type {Element}
   * @protected
   * @readonly
   */ 
  get _element() {
    return this.#element;
  }

  /**
   * The parent element.
   * @type {string}
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Node/parentElement}
   * @readonly
   */
  get parentElement() {
    return this.#element.parentElement;
  }

  /**
   * The class name.
   * @type {string}
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/className}
   */ 
  get className() {
    return this.#element.className;
  }

  /**
   * Set the class name.
   * @type {string}
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/className}
   * @ignore
   */ 
  set className(value) {
    this.#element.className = value;
  }

  /**
   * The classList.
   * @type {DOMTokenList}
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/classList}
   * @readonly
   */ 
  get classList() {
    return this.#element.classList;
  }

  /**
   * Element's offset height.
   * @type {number}
   * @readonly
   */
  get offsetHeight() {
    return this.#element.offsetHeight;
  }

  /**
   * Element's offset width.
   * @type {number}
   * @readonly
   */
  get offsetWidth() {
    return this.#element.offsetWidth;
  }

  /** 
   * Element's style.
   * @type {CSSStyleProperties} 
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style}
   * @readonly
   */
  get style() {
    return this.#element.style;
  }

  /**
   * Add event listener.
   * @param {string} eventType - Event type to listen to
   * @param {function|Object} listener - Handler of the event
   * @param {Object|boolean} optionsOrUseCapture - Additional options
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener}
   */ 
  addEventListener(eventType, listener, optionsOrUseCapture) {
    return this.#element.addEventListener(eventType, listener, optionsOrUseCapture);
  }

  /**
   * Append child.
   * @param {Element|ElementWrapper} child - Element to append
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild}
   */
  appendChild(child) {
    return this.#element.appendChild(child instanceof ElementWrapper ? child._element : child);
  }

  /**
   * Append to a parent element. Note, if it is already a child, this call is ignored.
   * This method is provided to allow callers to adust hierarchies without needing access to the protected
   * wrapped element. Note that the method will silently return if parentElement is not provided.
   * @param {module:hcje/domTools~ElementWrapper|HTMLElement} parentElement - The element to which the wrapped element
   *   should be appended.
   */
  appendTo(parentElement) {
    if (!parentElement) {
      return;
    }
    if (this.#element.parentElement) {
      console.error(`Attempt to append control as a child when it already has a parent. Ignored.`);
      return;
    } else {
      parentElement.appendChild(this.#element);
    }
  }
  
  /**
   * Remove the element from the DOM. 
   * The instance wrapping the element still exists if a reference to it is maintained.
   */
  remove() {
    this.#element.remove();
  }

  /**
   * Remove all children.
   */
  removeAllChildren() {
    this.#element.replaceChildren();
  }

  /**
   * Remove the element's focus.
   */
  blur() {
    this.#element.blur();
  }
  /**
   * Give the element focus.
   */
  focus() {
    this.#element.focus();
  }

  /**
   * Get a wrapper's to underlying element. 
   * @param {Element|ElementWrapper} item - The object to return as an Element.
   * @returns {Element}
   * @protected
   */ 
  static _toElement(item) {
    return item instanceof ElementWrapper ? item._element : item;
  }

}


/**
 * Simple text element wrapper which supports Markdown.
 */
export class TextElement extends ElementWrapper {

  /** 
   * Construct the TextElement class.
   * @extends module:hcje/domTools.ElementWrapper
   */
  constructor() {
    super('div');
    this.className = 'hcje-text';
  }

  /**
   * The innerText property of the underlying element.
   * @type {string}
   */
  get innerText() {
    return this._element.innerText;
  }

  /**
   * The innerText property of underlying element.
   * @type {string}
   * @ignore
   */ 
  set innerText(txt) {
    this._element.innerText = txt;
  }

  /**
   * Set markdown. This converts the text from Markdown to HTML and then updates the innerHtml of the underlying
   * element.
   * @param {string} markdown - The text to format and write.
   */
  setMarkdown(markdown) {
    this._element.innerHTML = parseMarkdown(markdown);
  }
}

/** Standard keyboard repeat delay in milliseconds.
 * @type {number}
 * @private
 */
const SIM_KBD_REPEAT_DELAY = 44;

/** Standard key board repeat interval in milliseconds.
 * @type {number}
 * @private
 */
const SIM_KBD_REPEAT_INTERVAL = 33;

/** 
 * Interval for button repeats.
 * @typedef {Object} ButtonRepeatInterval
 * @property {number} delay - Delay for first repeat in ms.
 * @property {number} repeat - Interval between subsequent repeats in ms.
 */

/** 
 * Simulated keyboard interval 
 * @type {module:hcje/domTools~ButtonRepeatInterval}
 */
export const SIM_KBD_INTERVAL = {delay: 750, repeat: 33};

/**
 * Class to handle button repeats. The handler also can be used to simulate clicks but on button down rather than up.
 * The button will use either [Pointer events]{@link https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events}
 * or [Touch events]{@link https://developer.mozilla.org/en-US/docs/Web/API/Touch_events} depending on availability.
 * @private
 */
class ButtonRepeater {
  /** @type {function} */
  #callback;
  /** @type {module:hcje/domTools~ButtonRepeatInterval} */
  #interval;
  /** @type {number} */
  #intervalId;
  /** @type {number} */
  #timeoutId;
  /** Event type in use.
   * @type {string} */
  #eventType;


  /**
   * Constuct the repeater.
   * @param {module:hcje/domTools~button} button - The button dispatching the event.
   * @param {module:hcje/domTools~ButtonRepeatInterval} interval - Intervals, delay and repeat, used for repetitions.
   * @param {function()} callback - The function to call when the button is held down.
   */
  constructor(button, interval, callback) {
    console.debug(`Button repeat  interval`, interval);
    this.#interval = interval;
    this.#callback = callback;
    if (device.supportsTouch()) {
      console.debug('Use touch events.');
      this.#eventType = 'touch';
      button.addEventListener('touchstart', (evt) => this.#start(evt));
      button.addEventListener('touchend', (evt) => this.#end(evt));
      button.addEventListener('touchmove', (evt) => evt.preventDefault());
      button.addEventListener('touchcancel', (evt) =>this.#end(evt));
    } else {
      this.#eventType = 'pointer';
      button.addEventListener('pointerdown', (evt) => this.#start(evt));
      button.addEventListener('pointerup', (evt) => this.#end(evt));
      button.addEventListener('pointercancel', (evt) => this.#end(evt));
      button.addEventListener('pointerleave', (evt) => this.#end(evt));
    }
  }

  /**
   * The event type used by this button.
   * @type {string}
   * @readonly
   */
  get eventType() {
    return this.#eventType;
  }

  /**
   * Start the repetitions. This call introduces a delay before the repetition begins. Note that if the delay is not
   * set, no repetitions will occur.
   * @param {Event} evt - The triggering event.
   * @private
   */
  #start(evt) {
    console.debug(`Button repeat start: ${evt.type}`);
    evt.preventDefault();
    this.#callback();
    if (this.#interval.delay) {
      this.#timeoutId = setTimeout(() => {
        this.#timeoutId = undefined;
        this.#repeat();
      }, this.#interval.delay);
    }
  }

  /**
   * Send notification to callback at the repeat rate.
   * @private
   */
  #repeat() {
    this.#callback();
    this.#intervalId = setInterval(() => this.#callback(), this.#interval.repeat);
  }

  /**
   * End the repetition. This handles the timeout and interval ids separately, although as the ids share the same pool,
   * they could have shared the same property. They have only be separated for clarity; see 
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/clearInterval}.
   * @private
   */
  #end(evt) {
    console.debug(`Button repeat end: ${evt.type}`);
    evt.preventDefault();
    if (this.#timeoutId) {
      clearTimeout(this.#timeoutId);
    } else {
      clearInterval(this.#intervalId);
    }
  }
}

/**
 * Button class which wraps a standard HTMLButtonElement and provides additional control over the presentation, 
 * especially when used as a toggle button.
 */
export class Button extends ElementWrapper {
  /** Button element.
   * @type {HTMLButtonElement} */
  #button;
  /** Repeater for buttons that repeat their actions like keys.
   * @type {ButtonRepeater} */
  #buttonRepeater;
  /** Is the button a toggle button.
   * @type {boolean} */
  #toggleButton;
  /** Button on state. Always false if not a toggle button. 
   * @type {boolean} */
  #buttonOn;
  /** Icon element.
   * @type {Element} */
  #icon;
  /** Text element for button face.
   * @type {Element} */
  #buttonText;
  /** Url for icon button.
   * @type {string} */
  #url;
  /** Url for icon button when down.
   * @type {string} */
  #urlOn;
  /** Label for text button.
   * @type {string} */
  #label;
  /** Label for text button when down.
   * @type {string} */
  #labelOn;
  /** Base class name.
   * @type {string} */
  #baseClass;
  /** Event type in use. 
   * @type {string} */
  #eventType;

  /**
   * Construct a button.
   * @param {module:hcje/domTools~ButtonConfig} config - Button configuration.
   * @extends module:hcje/domTools.ElementWrapper
   */ 
  constructor(config) {
    super('button');
    this.#toggleButton = !!config.urlOn || (!config.url && config.labelOn); 
    this.#url = config.url;
    this.#urlOn = config.urlOn;
    this.#label = config.label;
    this.#labelOn = config.labelOn;
    this.#buttonOn = !!config.down;
    
    this._element.tabindex = 0;
    this.#baseClass = this.#toggleButton ? 'hcje-toggle-button' : 'hcje-button';
    this._element.className = this.#baseClass;
    if (config.className) {
      this._element.classList.add(config.className);
    }
    this._element.classList.add(`${this.#baseClass}-${config.url ? 'image' : 'text'}`);

    if (config.url) {
      this.#icon = createChild(this._element, 'img', `${this.#baseClass}__icon`);
    } else {
      this._elementText = createChild(this._element, 'div', `${this.#baseClass}__label`);
    }
    this.#setButtonFace();
    this._element.title = config.label ?? '';

    config.parentElement?.appendChild(this._element);
  
    if (config.onClick && config.interval && !this.toggleButton) {
      this.#buttonRepeater = new ButtonRepeater(this, config.interval, config.onClick);
      this.#eventType = this.#buttonRepeater.eventType;
    }
    else if (config.onClick || this.#toggleButton) {
      this.#eventType = 'click';
      this.addEventListener('click', (ev) => {
        if (this.#toggleButton) {
          this.#buttonOn = !this.#buttonOn;
          this.#setButtonFace();
        }
        config?.onClick(ev, this.#buttonOn);
      });
    } else {
      this.#eventType = 'none';
    }

    this.addEventListener('contextmenu', (ev) => ev.preventDefault());
    this.addEventListener('dragstart', (ev) => ev.preventDefault());
  }

  /**
   * The event type used by this button.
   * @type {string}
   * @readonly
   */
  get eventType() {
    return this.#eventType;
  }

  /**
   * Set the src and class names for the button appropriate to its current state.
   * @private
   */
  #setButtonFace() {
    if (!this.#toggleButton) {
      if (this.#icon) {
        this.#icon.src = this.#url;
        this.#icon.setAttribute('alt', this.#label);
      }
      if (this._elementText) {
        this._elementText.innerText = this.#label;
      }
    } else {
      if (this.#buttonOn) {
        if (this.#icon) {
          this.#icon.src = this.#urlOn;
          this.#icon.setAttribute('alt', this.#labelOn || this.#label);
        } 
        if (this._elementText) {
          this._elementText.innerText = this.#labelOn;
        }
        this._element.classList.add(`${this.#baseClass}--down`);
        this._element.classList.remove(`${this.#baseClass}--up`);
      } else {
        if (this.#icon) {
          this.#icon.src = this.#url;
          this.#icon.setAttribute('alt', this.#label);
        } 
        if (this._elementText) {
          this._elementText.innerText = this.#label;
        }
        this._element.classList.add(`${this.#baseClass}--up`);
        this._element.classList.remove(`${this.#baseClass}--down`);
      }
    }
  }


  /**
   * Test if button is on. Always false if not a toggle button. 
   * @returns {boolean}
   */ 
  isOn() {
    return this.#buttonOn;
  }

  /**
   * Disabled state.
   * @type {boolean}
   */
  get disabled() {
    return this._element.disabled;
  }
  /**
   * Set disabled state.
   * @type {boolean}
   * @ignore
   */
  set disabled(disabledState) {
    this._element.disabled = disabledState;
  }
}


/**
 * Base control. A control in this context is some Element and an associated label held within a containing 
 * [HTMLDivElement]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement}. The basic structure
 * is shown below. Controls are expected to return a value by overriding the
 * {@link module:hcje/domTools~BaseControl#getValue} method.
 * Creates the control hierachy of
 *
 * + Container div
 *    + Label div
 *    + Control element
 */
class BaseControl extends ElementWrapper {
  /**
   * Construct the base control.
   * @param {string} className - Class name to add to the outer containing element.
   * @param {string} config
   * @param {string} config.label - Label for the control.
   * @param {module:hcje/domTools~ElementWrapper|HTMLElement} config.parentElement - Element to which the control is 
   *   appended.
   * @extends module:hcje/domTools.ElementWrapper
   */
  constructor(className, config) {
    super('div');
    this._element.className = `hcje-base-control ${className}`

    const labelElement = createChild(this._element, 'div', 'hcje-base-control__label' );
    labelElement.innerText = config.label;
    this.appendTo(config.parentElement);
  }

  /**
   * Add a control element. Although multiple control elements can be added, only one is expected.
   * This method adds it after the label.
   * @param {Element | module:hcje/domTools~ElementWrapper} controlElement - Control to add.
   * @protected
   */
  _appendControlElement(controlElement) {
    this._element.append(ElementWrapper._toElement(controlElement));
  }
  
  /**
   * Add a control element. Although multiple control elements can be added, only one is expected.
   * This adds it before the label.
   * @param {Element | module:hcje/domTools~ElementWrapper} controlElement - Control to add.
   * @protected
   */
  _prependControlElement(controlElement) {
    this._element.prepend(ElementWrapper._toElement(controlElement));
  }

  /**
   * Get the control's value. This is expected to be overridden;
   * @returns {undefined}
   */
  getValue() {
    console.error('BaseClass method getValue should be overridden.');
  }
}

/**
 * Button control. This is effectively just a [Button]{@link module:hcje/domTools~Button} with an associated label.
 */
export class ButtonControl extends BaseControl {
  /** The button.
   * @type {module:hcje/domTools~Button} */
  #button;

  /**
   * Create a labelled button for use as a control.
   * The structure generated by the code is effectively
   *
   * + div: className hcje-button-control
   *     + span hcje-control-label
   *     + input classNames hcje-button
   *
   * @param {module:hcje/domTools~ButtonConfig} config - Button configuration.
   * @extends module:hcje/domTools.BaseControl
   */
  constructor(config) {
    super('hcje-button-control', config);
    
    this.#button = new Button({
      parentElement: this._element,
      url: config.url,
      urlOn: config.urlOn,
      on: config.on,
      label: config.label,
      labelOn: config.labelOn,
      className: config.className,
      onClick: config.onClick
    });

    this.appendTo(config.parentElement);
  }

  /**
   * Get the state of the button. True if down. This is only relevant to a toggle button.
   * @returns {boolean}
   */ 
  getValue() {
    return this.#button.isOn();
  }
}

/**
 * Callback function for a checkbox.
 * @callback module:hcje/domTools.CheckboxControl~onChangeCallback
 * @param {boolean} checked - True if the current state is checked.
 */

/**
 * Checkbox control. Simple implementation of a checkbox.
 */
export class CheckboxControl extends BaseControl {
  /** Checkbox element.
   * @type {Element} */
  #box;
  /** State of button. 
   * @type {boolean} */
  #checked;
  /** Function called on change.
   * @type {module:hcje/domTools.CheckboxControl~onChangeCallback} */
  #onChange;
  /** Class name when checked.
   * @type{string} */
  #checkedClassName;


  
  /**
   * Construct the checkbox
   * @param {Object} config
   * @param {string} config.label - Checkbox label
   * @param {boolean} config.initialValue - True if initial state is checked
   * @param {boolean} config.tick - True if a tick should be used in place of the default cross.
   * @param {module:hcje/domTools.CheckboxControl~onChangeCallback} onChange - Function called if state changes.
   * @extends module:hcje/domTools.BaseControl
   */
  constructor(config) {
    super('hcje-checkbox-control', config);

    this.#box = document.createElement('div');
    this.#box.className  ='hcje-checkbox-control__box';
    this.#checkedClassName = `hcje-checkbox-control__box--checked-${config.tick ? 'tick' : 'cross'}`;
    this.#onChange = config.onChange;
    this.#box.addEventListener('click', () => {
      this.#setState(!this.#checked)
      this.#onChange?.(this.#checked);
    });
    this._prependControlElement(this.#box);
    this.#setState(!!config.initialValue);
  }
  

  /**
   * Set state.
   * @param {boolean} checkedState - The new state.
   * @private
   */
  #setState(checkedState) {
    this.#checked = checkedState;
    if (this.#checked) {
      this.#box.classList.add(this.#checkedClassName);
      this.#box.classList.remove('hcje-checkbox-control__box--unchecked');
    } else {
      this.#box.classList.remove(this.#checkedClassName);
      this.#box.classList.add('hcje-checkbox-control__box--unchecked');
    }
  }
  
  /**
   * Get checked state.
   * @returns {boolean} True if currently checked.
   */ 
  getValue() {
    return this.#checked;
  }
}

/**
 * Constraint function for input controls. This is called on the **input** event and should return 
 * the constrained value. Typically, if the current input fails the constraint requirements, the
 * lastValidInput should be returned.
 *
 * @callback ConstrainInput
 * @param {string} lastValidInput - The last value which satisified the constraint.
 * @param {string} currentInput - The current value input before application of the constraint.
 * @returns {string} The constrained value.
 */ 

/**
 * Callback function for a InputControl.
 * @callback module:hcje/domTools.InputControl~onChangeCallback
 * @param {string} value - Current value
 */

/**
 * Input control. This provides a text input field.
 */
export class InputControl extends BaseControl {
  #input;
  #lastValidInput;

  /**
   * Construct the input control.
   * For more details on the configuration options, see 
   * [input text type]{@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/text}.
   * @param {Object} config
   * @param {Element} config.parentElement - Element to attach to.
   * @param {string} config.label - Label for the control
   * @param {number} [config.initialValue = 0] - Initial value.
   * @param {number} config.maxLength - Maximum length of the input
   * @param {number} config.minLength - Minimum length of the input
   * @param {string} config.placeholder - Hint for input.
   * @param {module:hcje/domTools.InputControl~onChangeCallback} config.onChange - Function to call on change.
   * @param {module:hcje/domTools~ConstrainInput|string} config.constrain - Function to call on input. 
   * If a string is provided, it should be the name of an in-built function: FLOAT, +FLOAT, INT or +INT. These in-built
   * functions are case insensitive. Those preceded by + character constrain the input to positive values.
   * If an initial value and constraint are provided, the initial value is parsed by the constrain function before use.
   * @extends module:hcje/domTools.BaseControl
   */ 
  constructor(config) {
    super('hcje-input-control', config);
    this.#input = document.createElement('input');
    this.#input.className = 'hcje-input-control__input';
    //this.#input.maxLength = config.maxLength ?? undefined;
    //this.#input.minLength = config.minLength ?? undefined;
    this.#input.placeHolder = config.placeholder;
    this.#input.value = config.initialValue ?? '';
    if (config.onChange) {
      this.#input.addEventListener('change', (evt) => {
        config.onChange(this.#input.value);
      });
    }
    this.#lastValidInput = this.#input.value;
    const constraintFunction = this.#getConstraintFunction(config.constrain);
    if (constraintFunction) {
      this.#input.value = constraintFunction(this.#input.value);
      this.#lastValidInput = this.#input.value;
      this.#input.addEventListener('input', (evt) => {
        this.#input.value = constraintFunction(this.#lastValidInput, this.#input.value);
        this.#lastValidInput = this.#input.value;
      });
    }
    this._appendControlElement(this.#input);
  }

  /**
   * Regex constraint. This tests the current input against a regular expression and returns the current input if the 
   * test passes or the lastValidInput if it fails.
   * @param {RegExp} regex - Regex to test input against.
   * @param {string} lastValidInput - Last valid input.
   * @param {string} currentInput - Current input.
   * @returns {string}
   */
  constrainToRegex(regex, lastValidInput, currentInput) {
    console.debug(`Test ${currentInput}. Last valid = ${lastValidInput}`);
    return regex.test(currentInput) ? currentInput : lastValidInput;
  }

  
  /**
   * Get a suitable constraint function based on the constraint value.
   * If a string is provided, it needs to be either INT or FLOAT to use an in-build function.
   * Otherwise it is assumed to be a constraint function itself and is returned as is.
   * @param {module:hcje/domTools~ConstrainInput|RegExp|string} constrain - The function to call on input. If a 
   * **RegExp** is provided, input is constrained to match the regular expression. If a string is provided, it should
   * match an in-built function of 'FLOAT', '+FLOAT', 'INT', or '+INT'. If not a regular expression or string, 
   * it should be constrain function which will be returned as is.
   * @returns {module:hcje/domTools~ConstrainInput}
   * @private
   */ 
  #getConstraintFunction(constrain) {
    if (constrain instanceof RegExp) {
      return (lastValid, current) => this.constrainToRegex(constrain, lastValid, current);
    } else if (typeof constrain === 'string') {
      switch (constrain.toUpperCase()) {
        case 'FLOAT': 
          return (lastValid, current) => this.constrainToRegex(/^[+-]?\d*[.]?\d*$/, lastValid, current);
        case '+FLOAT':
          return (lastValid, current) => this.constrainToRegex(/^[+]?\d*[.]?\d*$/, lastValid, current);
        case 'INT':
          return (lastValid, current) => this.constrainToRegex(/^[+-]?\d*$/, lastValid, current);
        case '+INT':
          return (lastValid, current) => this.constrainToRegex(/^[+]?\d*$/, lastValid, current);
        default: 
          console.error(`Invalid constraint function ${constraintValue} ignored.`);
          return;
      }
    }
    return constrain;
  }
  
  /**
   * Get current value as string.
   * @returns {string}
   */ 
  getValue() {
    return this.#input.value;
  }
  
}

/**
 * Callback function for a SpinnerControl.
 * @callback module:hcje/domTools.SpinnerControl~onChangeCallback
 * @param {number} value - Current value. This is the numerical value, not the formatted value.
 */
/**
 * Formatting function for a SpinnerControl.
 * @callback module:hcje/domTools.SpinnerControl~format
 * @param {number} value - Current value. This is the numerical value, no the formatted value.
 * @returns {string} Formatted version of the numerical value.
 */

/**
 * Spinner control.
 */
export class SpinnerControl extends BaseControl {
  /** Change in value per click.
   * @type {number} */
  #step;
  /** Minimum value.
   * @type {number} */
  #minValue;
  /** Maximum value.
   * @type {number} */
  #maxValue;
  /** Underlying spinner value.
   * @type {number} */
  #value;
  /** Element containing the value.
   * @type {Element} */
  #valueElement;
  /** Down button.
   * @type {Element} */
  #downButton;
  /** Up button.
   * @type {Element} */
  #upButton;
  /** Function called on change.
   * @type {module:hcje/domTools.SpinnerControl~onChangeCallback} */
  #onChange;
  /** Function called to format displayed value.
   * @type {module:hcje/domTools.SpinnerControl~format} */
  #format;

  /**
   * Create spinner control.
   *
   * The structure generated by the code is effectively
   *
   * + div: className hcje-spinner-control
   *     + span hcje-control-label
   *     + div className hcje-spinner-control__inner
   *         + input classNames hcje-button hcje-spinner-control__inner__down-button
   *         + div className hcje-spinner-control__inner__value
   *         + input classNames hcje-button hcje-spinner-control__inner__up-button
   *
   * @param {Object} config
   * @param {Element} config.parentElement - element to attach to.
   * @param {string} config.label - Label for spinner control
   * @param {number} [config.initialValue = 0] - Initial value.
   * @param {string} config.downImage - Url to down button image
   * @param {string} config.upImage - Url to up button image
   * @param {string} [config.downLabel = 'Reduce'] - Label for down button.
   * @param {string} [config.upLabel = 'Increase'] - Label for up button.
   * @param {number} [config.step = 1] - Change in value per click
   * @param {number} [config.minValue = 0] Minimum value
   * @param {number} [config.maxValue = 100] Maximum value
   * @param {module:hcje/domTools.SpinnerControl~format} format - Formatting function. This takes the value and returns formated value.
   * This is called before **onChange**.
   * @param {module:hcje/domTools.SpinnerControl~onChangeCallback} onChange - called with new value after changes. Note it is provided with the spinner's
   * numerical value, **not** its formatted value.
   * @extends module:hcje/domTools.BaseControl
   */
  constructor(config) {
    super('hcje-spinner-control', config);
    this.#step = config.step || 1;
    this.#minValue = config.minValue ?? 0;
    this.#maxValue = config.maxValue ?? 100;
    this.#value = 0;
    this.#onChange = config.onChange;
    this.#format = config.format;

    const innerContainer = document.createElement('div');
    innerContainer.className ='hcje-spinner-control__inner ';

    this.#downButton = new Button({
      parentElement: innerContainer, 
      url: config.downImage,
      label: config.downLabel ?? '-',
      className: 'hcje-spinner-control__inner__down-button',
      onClick: () => this.#setValueAndNotify(this.#value - this.#step)
    }) 

    this.#valueElement = createChild(innerContainer, 'div', 'hcje-spinner-control__inner__value');

    this.#upButton = new Button({
      parentElement: innerContainer, 
      url: config.upImage,
      label: config.upLabel ?? '+',
      className: 'hcje-spinner-control__inner__up-button',
      onClick: () => this.#setValueAndNotify(this.#value + this.#step)
    });

    this.#setValue(config.initialValue ?? 0);

    this._appendControlElement(innerContainer);
  }

  /** 
   * Function to set value.
   * @param {number} newValue - The new value for the spinner.
   * @private
   */
  #setValue(newValue) {
    this.#value = utils.clamp(newValue, this.#minValue, this.#maxValue);
    this.#downButton.disabled = this.#value <= this.#minValue;
    this.#upButton.disabled = this.#value >= this.#maxValue;

    this.#valueElement.innerText = this.#format ? this.#format(this.#value) : `${this.#value}`;
  }

  /**
   * Function to set value and inform owner of change.
   * @param {number} newValue - The new value for the spinner.
   * @private
   */
  #setValueAndNotify(newValue) {
    this.#setValue(newValue);
    this.#onChange?.(newValue);
  }

  /**
   * Get the current value.
   * @returns {number}
   */
  getValue() {
    return this.#value;
  }
}

/**
 * Encapsulation of a DOM menu bar.
 */
export class MenuBar {
  /** The menubar element.
   * @type {HTMLElement} */
  #menuBar;

  /** The element that opens the menu.
   * @type {module:hcje/domTools~ElementWrapper|HTMLElement} */
  #opener;
  /** The element that closes the menu.
   * @type {module:hcje/domTools~ElementWrapper|HTMLElement} */
  #closer;
  /** Function called when menu opened.
   * @type {function()} */
  onOpen;
  /** Function called when menu closed.
   * @type {function()} */
  onClose;




/**
 * Construct a menu bar. The opener and closer elements are added first followed
 * by the children. 
 *
 * The structure generated by the code is effectively
 *
 * + div: className hcje-menu-bar-container
 *     + config.opener element
 *     + div className hcje-menu-bar
 *         + config.closer
 *         + config.children
 *
 * Listeners are automatically added to the opener and closer
 * to set the class of the menu bar container to 'open' when open.
 * It is expected that the the display of these elements will
 * be controlled by CSS.
 *
 * There is no need to add the menu bar as this is done automatically in the constructor. It is either added to the
 * `config.parentElement` or the `document.body`.
 * 
 * @param {Object} config
 * @param {Element} [config.parentElement = document.body] - The menu element is added to the parent
 * @param {module:hcje/domTools.ElementWrapper|Element} config.opener - Element used to open and close the menu bar.
 * @param {module:hcje/domTools.ElementWrapper|Element} config.closer - Element used to close the menu bar
 * @param {module:hcje/domTools.ElementWrapper|Element} config.children - Buttons to add to menu
 * @param {function()} [config.onOpen] - Function called when menu opened via the opener. 
 * @param {function()} [config.onClose] - Function called when menu closed via the closer.
 */
  constructor(config) {
    this.#menuBar = document.createElement('div');
    this.#menuBar.className = 'hcje-menu-bar';
    this.onOpen = config.onOpen;
    this.onClose = config.onClose;
    this.#opener = config.opener;
    this.#closer = config.closer;

    const parentElement = config.parentElement || document.body;
    parentElement.appendChild(this.#menuBar);

    if (this.#opener) {
      if (this.#opener instanceof ElementWrapper) {
        this.#opener.appendTo(parentElement);
      } else {
        parentElement.appendChild(this.#opener);
      }
      this.#opener.classList.add('hcje-menu-opener');
      this.#opener.classList.add('hcje-menu-opener--visible');
      this.#opener.addEventListener('click', () => {
        this.#setMenuOpen(true);
        this.onOpen?.();
      });
      this.#opener
    }


    if (this.#closer) {
      if (this.#closer instanceof ElementWrapper) {
        this.#closer.appendTo(this.#menuBar);
      } else {
        this.#menuBar.appendChild(this.#closer);
      }
      this.#closer.addEventListener('click', () => {
        this.#setMenuOpen(false);
        this.onClose?.();
      });
    }

    for (const child of config.children) {
      if (child instanceof ElementWrapper) {
        child.appendTo(this.#menuBar);
      } else {
        this.#menuBar.appendChild(child);
      }
    }
   
    this.#setMenuOpen(false);
  }
  
  /** Set the menu state.
   * @param {boolean} open - True if open.
   * @private
   */ 
  #setMenuOpen(open) {
      this.#opener.classList.remove(`hcje-menu-opener--${open ? 'visible' : 'hidden'}`);
      this.#opener.classList.add(`hcje-menu-opener--${!open ? 'visible' : 'hidden'}`);
      this.#menuBar.classList.remove(`hcje-menu-bar--${!open ? 'visible' : 'hidden'}`);
      this.#menuBar.classList.add(`hcje-menu-bar--${open ? 'visible' : 'hidden'}`);
  }

  /**
   * Close the menu bar.
   */
  close() {
    this.#setMenuOpen(false);
    this.onClose?.();
  }
  
  /**
   * Set the enabled state of the menu. If the state is false, all buttons are
   * disabled and the menu is closed.
   * @param {boolean} enabled - Required state.
   */
  setEnabledState(enabled) {
    const openers = document.getElementsByClassName('hcje-menu-opener');
    for (const opener of openers) {
      opener.disabled = !enabled;
    }
    const allInputs = document.querySelectorAll(`hcje-menu-opener input`);
    allInputs.forEach((input) => input.disabled = !enabled);
    if (!enabled) {
      this.#setMenuOpen(false);
    }
  }
  
  /** 
   * Enabled the menu. The container has the disabled class removed and all clicks
   * are activated.
   */
  enable() {
    this.setEnabledState(true);
  }
  /** 
   * Disable the menu. The container has the disabled class set and all clicks
   * are ignored.
   */
  disable() {
    this.setEnabledState(false);
  }

  /**
   * Remove the menu.
   */
  remove() {
    this.#menuBar.remove();
  }
}


/**
 * Definition of a button for use as a button that closes the dialog and returns an id.
 * @typedef {Object} DialogButtonDefn
 * @property {string} id - Id of the button used as the dialog's return value
 * @property {string} url - Path to the image used on the button
 * @property {string} label - Label of the button.
 */

/**
 * Create a dialog. Styling is the responsibility of CSS, but the structure
 * created is as follows:
 *
 * + div container className hcje-dialog-mask
 * + div className hcje-dialog-box
 *     + div className hcje-dialog-box__title
 *     + div className hcje-dialog-box__body
 *     + div className hcje-dialog-box__buttons
 *         + div buttons className hcje-dialog-box__button
 *
 * If any button is pressed, the dialog closes and the id of the button
 * provided by the return.
 * @param {Object} config
 * @param {string} config.title - Title written in the dialog's title bar.
 * @param {string} [config.className] - Additional class to apply.
 * @param {string} [config.markdown] - The body content provided as markdown. If set, overrides the text option.
 * @param {string} [config.text] - The body content provided as plain text.
 * @param {Array<module:hcje/domTools~ElementWrapper|Element>} [config.children] - add as children to dialog.
 * @param {Array<module:hcje/domTools~DialogButtonDefn>} config.buttonDefns - Buttons placed at the bottom of the dialog 
 * to close it. If no label is provided, the button's id is used. 
 * @returns {Promise} Fulfils to the id of button that closed the dialog.
 */
export function createDialog(config) {
  const DIALOG_CLASS_NAME = 'hcje-dialog-box';
  const BASE_Z_INDEX = 1000;

  const existingDialogs = document.querySelectorAll(`.${DIALOG_CLASS_NAME}`).length;
  console.debug(`Creating dialog on top of ${existingDialogs} existing dialogs.`);
  const mask = document.createElement('div');
  mask.className = 'hcje-dialog-mask hcje-fullscreen';

  
  const box = document.createElement('div');
  box.className = DIALOG_CLASS_NAME;
  if (config.className) {
    box.classList.add(config.className);
  }

  const header = createChild(box, 'div', 'hcje-dialog-box__title')
  createChild(header,'h1').innerText = config.title;

  const dialogBody = createChild(box, 'div', 'hcje-dialog-box__body');
  if (config.markdown) {
    dialogBody.innerHTML = parseMarkdown(config.markdown);
  } else if (config.text) {
    dialogBody.innerText = config.text;
  }

  if (config.children) {
    for (const child of config.children) {
      if (child instanceof ElementWrapper) {
        child.appendTo(dialogBody);
      } else {
        dialogBody.appendChild(child);
      }
    }
  }
  const buttonBar = createChild(box, 'div', 'hcje-dialog-box__buttons');
  document.body.appendChild(mask);
  document.body.appendChild(box);

  const promises = [];
  for (const defn of config.buttonDefns) {
    const button = new Button({
      parentElement: buttonBar,
      url: defn.url,
      label: defn.label ?? defn.id,
      className: 'hcje-dialog-box__button'
    });
    promises.push(new Promise((resolve) => {
      button.addEventListener('click', () => {
        mask.style.opacity = 0;
        box.style.opacity = 0;
        setTimeout(() => {
          mask.remove();
          box.remove();
          resolve(defn.id);
        }, 500);
      }, {once: true})
    }));
    if (promises.length === 1) {
      button.focus();
    }
  }
  
  mask.style.zIndex = BASE_Z_INDEX + existingDialogs * 2;
  box.style.zIndex = BASE_Z_INDEX + existingDialogs * 2 + 1;
  return Promise.any(promises);
}



/**
* GameArea object which encapsulates the dynamic game area. 
* This area is a div which is centred on screen and then scaled to ensure it fits the screen or parent element.
*/
export class GameArea extends ElementWrapper {
  /** Design width.
   * @type {number} */
  #width;
  /** Design height.
   * @type {number} */
  #height;
  /** Element in which game area should fit.
   * @type {Element} */
  #fitWithin;
  /** Was the fitWithin element created.
   * @type {boolean} */
  #internalContainer;
  /** Margin around the game area.
   * @type {number} */
  #margin;
  /** Max permitted scale.
   * @type {number} */
  #maxScale;
  /** Should the game area be positioned at the top rather than the centre.
   * @type {boolean} */
  #atTop;


  /** 
   * Create a game area. The game area is created with a class of 'hcje-game-area'. This is absolutely positioned
   * and centered.
   * @param {Object} config
   * @param {number} config.width - Design width
   * @param {number} config.height - Design height
   * @param {Element} [config.fitWithin] - Element into which the game area should fit. If omitted a div that covers
   * the full size of the window is created.
   * @param {number} [config.margin = 0] - Margin required around the game area.
   * @param {number} [maxScale] - Maximum allowed scale. Defaults to unlimited.
   * @param {boolean} [fixedScale = false] - If true, prevents automatically rescaling if window resizes.
   * @param {boolean} [atTop = false] - The game area is normally centered but it can be set to the top but still
   * centered horizonally.
   * @extends module:hcje/domTools.ElementWrapper
   */
  constructor(config) {
    super('div');
    if (config.fitWithin) {
      this.#fitWithin = config.fitWithin;
      this.#internalContainer = false; 
    } else {
      this.#fitWithin = createChild(document.body, 'div', 'hcje-game-area-container hcje-fullscreen');
      this.#internalContainer = true; 
    }

    this.appendTo(this.#fitWithin);

    this.className = 'hcje-game-area';
    console.log(`GameArea element ${this._element}`);
    this._element.style.width = `${config.width}px`;
    this._element.style.height = `${config.height}px`;
    this.#width = config.width;
    this.#height = config.height;
    this.#margin = config.margin;
    this.#maxScale = config.maxScale;
    this.#atTop = config.atTop;
    this.#rescale();
    if (!config.fixedScale) {
      console.debug(`Add resize event listener to rescale game area on window change.` );
      addEventListener('resize', () => this.#rescale());
    }
  }
  /**
   * Calculate required scale for game area and apply to the game area.
   * @see {module:hcje/domTools~GameArea.rescale}
   * @private
   */
  #rescale() {
    let scale = device.getScaleToFit(this.#width, this.#height, {
      element: this.#fitWithin,
      margin: this.#margin
    });
    if (this.#maxScale) {
      scale = Math.min(this.#maxScale, scale);
    }
    if (this.#atTop) {
      this._element.style.top = `${this.#margin + 0.5 * this.#height * (scale - 1) }px`; 
      this._element.style.transform = `translate(-50%) scale(${scale})`;
    } else {
      this._element.style.transform = `translate(-50%, -50%) scale(${scale})`;
    }
    console.debug(`Game area size: design [${this.#width}x${this.#height} at scale of ${scale.toFixed(2)};`);
  }
  
  /**
   * Get the design bounds.
   * @type {module:hcje/utils~RectData}
   * @readonly
   */
  get designBounds() { 
    return {x: 0, y: 0, width: this.#width, height: this.#height}; 
  }
  
  /**
   * Get the design dimensions.
   * @type {module:hcje/utils~Dimensions}
   * @readonly
   */
  get designDims() { 
    return {width: this.#width, height: this.#height}; 
  }

  /**
   * Get the scaled dimensions.
   * @type {module:hcje/utils~Dimensions}
   * @readonly
   */
  get scaledDims() {
    return {width: this.#width * scale, height: this.#height * scale}; 
  }

  /**
   * @inheritdoc
   */
  remove() {
    super.remove();
    if (this.#internalContainer) {
      this.#fitWithin.remove();
    }
  }
} 

/**
 * Indicator for showing progress.
 * @interface BusyIndicator
 */

/**
 * Start the indicator.
 * This should only be called once. Calling start multiple times results in an error being thrown.
 * @function module:hcje/domTools~BusyIndicator#start
 * @throws {Error}
 */

/**
 * End the busy indicator. If there is an associated timeout, this is cleared. Once called
 * the instance has no further use and any references should be discarded.
 * @function module:hcje/domTools~BusyIndicator#end
 */

/**
 * Busy indicator that displays an indicator in the DOM. It includes a timeout that defaults to 15 seconds
 * @implements module:hcje/domTools~BusyIndicator
 */ 
export class TimeLimitedBusyIndicator {
  /** Visual indicator.
   * @type {Element} */
  #element;
  /** Aria label.
   * @type {string} */
  #label;
  /** Timeout in seconds.
   * @type {number} */
  #timeoutS;
  /** Timeout message.
   * @type {string} */
  #timeoutMessage;
  /** Timeout timer id.
   * @type {number} */
  #timerId;
  /** Started flag.
   * @type {boolean} */
  #started;

  /**
   * Create a busy indicator.
   * The indicator is automatically added to the DOM. If the timeoutSeconds are > 0, then the **end** method **must** be called,
   * otherwise the indicator will time out and the user will be offered the chance to navigate back in history or reload.
   * @param {Object} options
   * @param {string} [options.label = 'Busy'] - Label added as an **aria-label**.
   * @param {number} [options.timeoutS = 15] - Timeout in seconds.
   * @param {string} [options.timeoutMessage = default message] - Message to display on timeout. This should include a prompt that that
   * if OK is selected the page will wait, and if cancel is selected the page will reload.
   * The default message, which has no localisation, is
   * "The last action is taking too long. Do you want to wait? If you cancel, the game will reload.";
   */ 
  constructor(options) {
    this.#label = options?.label ?? 'Busy';
    this.#timeoutS = options?.timeoutS ?? 15;
    this.#timeoutMessage = options?.timeoutMessage ??
        'The last action is taking too long. Do you want to wait? If you cancel, the game will reload.';
    this.#started = false;
  }

  /**
   * Create the busy indicator.
   * @private
   */
  #createIndicator() {
    this.#element = createChild(document.body, 'progress', 'hcje-busy-indicator');
    this.#element.setAttribute('aria-label', this.#label);
  }

  /**
   * Activate the timeout handler. If a timeout occurs, the confirm dialog displays the timeout message.
   * If cancel is selected, the page reloads, otherwise the timeout handler is reactivated.
   * @private
   */
  #activateTimeoutHandler() {
    this.#timerId = setTimeout(() => {
      console.warn(`Timeout occured. Ask user for next action: ${this.#timeoutMessage}`);
      if (confirm(this.#timeoutMessage)) {
        console.debug('User selected to keep waiting.');
        this.#activateTimeoutHandler();
      } else {
        console.debug('User selected to reload.');
        location.reload();
      }
    }, this.#timeoutS * 1000);
  }

  /**
   * @inheritdoc
   */
  start() {
    if (this.#started) {
      throw new Error('Attempt made to restart a BusyIndicator');
    }
    this.#started = true;
    this.#createIndicator();
    if (this.#timeoutS > 0) {
      this.#activateTimeoutHandler();
    }
  }
  /**
   * The instance has no further use and should be discarded.
   * @inheritdoc
   */
  end() {
    clearTimeout(this.#timerId);
    this.#element.remove();
  }
}