/**
* @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: '&'},
{re: /</g, rep: '<'},
{re: />/g, rep: '>'},
{re: /"/g, rep: '"'},
{re: /'/g, rep: '''},
/* 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: /<br>/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?]+?)(?: *"([\w ]*?)")?\)/gm, rep: '<img alt = "$1" src="$2" title="$3" />'},
{re: /\[([\w ,;.&-]+?)\]\(((?:https:\/\/|\.\/)[^\s?]+?)(?: *"([\w ]*?)")?\)/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 <
*
* @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();
}
}