bondage-college-mirr/BondageClub/Scripts/Element.js

1878 lines
63 KiB
JavaScript

"use strict";
/**
* Handles the value of a HTML element. It sets the value of the element when the Value parameter is provided or it returns the value when the parameter is omitted
* @param {string} ID - The id of the element for which we want to get/set the value.
* @param {string} [Value] - The value to give to the element (if applicable)
* @returns {string} - The value of the element (When no value parameter was passed to the function)
*/
function ElementValue(ID, Value) {
const e = /** @type {HTMLInputElement} */(document.getElementById(ID));
if (!e) {
console.error("ElementValue called on a missing element: " + ID.toString());
return "";
}
if (Value == null)
return e.value.trim();
e.value = Value;
return "";
}
/**
* Disable all clickable elements within `root` for the given duration.
* @param {Element} root - The root element
* @param {null | string} [query] - The query for identifying all clickable elements within `root`
* @param {number} [timeout] - The timeout in ms
* @returns {number} - The timeout ID as returned by {@link setTimeout}
*/
function ElementClickTimeout(root, query=null, timeout=250) {
query ??= "button,a,input,select,radio,[role='button'],[role='link']";
const elemClickable = /** @type {HTMLElement[]} */(Array.from(root.querySelectorAll(query)));
const elemDisabledStates = elemClickable.map(e => /** @type {const} */([e, e.hasAttribute("disabled"), e.getAttribute("aria-disabled")]));
elemDisabledStates.forEach(([e, disabled, ariaDisabled]) => {
if (!disabled && ariaDisabled !== "true") {
e.toggleAttribute("disabled", true);
e.setAttribute("aria-disabled", "true");
}
});
return setTimeout(() => {
elemDisabledStates.forEach(([e, disabled, ariaDisabled]) => {
if (!disabled) {
e.toggleAttribute("disabled", false);
}
if (ariaDisabled !== "true") {
e.removeAttribute("aria-disabled");
}
});
}, timeout);
}
/**
* Handles the content of a HTML element. It sets the content of the element when the Content parameter is provided or it returns the value when the parameter is omitted
* @param {string} ID - The id of the element for which we want to get/set the value.
* @param {string} [Content] - The content/inner HTML to give to the element (if applicable)
* @returns {string} - The content of the element (When no Content parameter was passed to the function)
*/
function ElementContent(ID, Content) {
const e = document.getElementById(ID);
if (!e) {
console.error("ElementContent called on a missing element: " + ID.toString());
return "";
}
if (Content == null)
return e.innerHTML;
e.innerHTML = Content;
return "";
}
/** @satisfies {ElementNoParent} */
const ElementNoParent = 0;
/**
* @template {keyof HTMLElementScalarTagNameMap} T
* @param {HTMLOptions<T>} options - Options for customizing the element
* @returns {HTMLElementTagNameMap[T]} - The created element
*/
function ElementCreate(options) {
const elem = document.createElement(options.tag);
for (const [k, v] of Object.entries(options.attributes ?? {})) {
if (v == null || v === false) {
continue;
} else if (v === true) {
elem.toggleAttribute(k, true);
} else {
elem.setAttribute(k, v);
}
}
for (const [eventName, listener] of Object.entries(options.eventListeners ?? {})) {
if (listener != null) {
elem.addEventListener(eventName, listener);
}
}
for (const [k, v] of Object.entries(options.style ?? {})) {
if (v != null) {
elem.style.setProperty(k, /** @type {any} */(v));
}
}
for (const [k, v] of Object.entries(options.dataAttributes ?? {})) {
if (v == null || v === false) {
continue;
} else if (v === true) {
elem.dataset[k] = "";
} else {
elem.dataset[k] = v.toString();
}
}
for (const cls of options.classList ?? []) {
if (cls != null) {
elem.classList.add(cls);
}
}
if (options.innerHTML) { elem.innerHTML = options.innerHTML; }
/** @type {(i: unknown) => i is Node | string} */
const isNode = (i) => typeof i === "string" || (typeof i === "object" && "nodeValue" in i);
for (const childElem of options.children ?? []) {
if (childElem != null) {
if (isNode(childElem)) {
elem.append(childElem);
} else {
ElementCreate({ ...childElem, parent: elem });
}
}
}
if (options.parent) { options.parent.appendChild(elem); }
return elem;
}
/**
* Creates a new from element in the main document.
*
* @param {string} ID - The id of the form to create
* @returns {HTMLFormElement}
*/
function ElementCreateForm(ID) {
return /** @type {HTMLFormElement} */ (document.getElementById(ID)) ?? ElementCreate({
tag: "form",
attributes: {
id: ID,
name: ID,
method: "dialog",
["screen-generated"]: CurrentScreen,
},
parent: document.body,
});
}
/**
* Creates a new text area element in the main document. Does not create a new element if there is already an existing one with the same ID
* @param {string} ID - The id of the text area to create.
* @param {HTMLElement} [form] - The form the element belongs to
* @returns {HTMLTextAreaElement}
*/
function ElementCreateTextArea(ID, form) {
return /** @type {HTMLTextAreaElement} */ (document.getElementById(ID)) ?? ElementCreate({
tag: "textarea",
attributes: {
id: ID,
name: ID,
["screen-generated"]: CurrentScreen,
},
parent: form ?? document.body,
classList: ["HideOnPopup"],
});
}
/**
* Blur event listener for `number`-based `<input>` elements that automatically sanitizes the input value the moment the element is deselected.
* @this {HTMLInputElement}
* @param {FocusEvent} event
*/
function ElementNumberInputBlur(event) {
let value = "";
if (Number.isNaN(this.valueAsNumber)) {
value = this.defaultValue;
} else {
const min = this.min ? Number(this.min) : -Infinity;
const max = this.max ? Number(this.max) : Infinity;
const requiresInt = this.inputMode === "numeric";
value = CommonClamp(
requiresInt ? Math.round(this.valueAsNumber) : this.valueAsNumber,
Number.isNaN(min) ? -Infinity : min,
Number.isNaN(max) ? Infinity : max,
).toString();
}
if (value !== this.value) {
this.value = value;
this.dispatchEvent(new Event("input"));
this.dispatchEvent(new Event("change"));
}
}
/**
* Wheel event listener for `number`-based `<input>` elements. Allows one to increment/decrement the value
* @this {HTMLInputElement}
* @param {WheelEvent} event
*/
function ElementNumberInputWheel(event) {
if (this.disabled || this.readOnly) {
event.stopImmediatePropagation();
return;
}
let min = this.min ? Number(this.min) : -Infinity;
let max = this.max ? Number(this.max) : Infinity;
let step = this.step ? Number(this.step) : 1;
if (Number.isNaN(min)) { min = -Infinity; }
if (Number.isNaN(max)) { max = Infinity; }
if (Number.isNaN(step)) { step = 1; }
let value = this.valueAsNumber;
if (event.deltaY < 0) {
value = CommonClamp(value + step, min, max);
} else if (event.deltaY > 0) {
value = CommonClamp(value - step, min, max);
}
if (value !== this.valueAsNumber) {
this.valueAsNumber = value;
this.dispatchEvent(new Event("input"));
if (document.activeElement !== this) {
this.dispatchEvent(new Event("change"));
}
}
event.preventDefault();
event.stopPropagation();
}
/**
* Creates a new text input element in the main document.Does not create a new element if there is already an existing one with the same ID
* @param {string} ID - The id of the input tag to create.
* @param {string} Type - Type of the input tag to create.
* @param {string} Value - Value of the input tag to create.
* @param {string | number} [MaxLength] - Maximum input tag of the input to create.
* @param {Node} [form] - The form the element belongs to
* @returns {HTMLInputElement} - The created HTML input element
*/
function ElementCreateInput(ID, Type, Value, MaxLength, form) {
let e = /** @type {HTMLInputElement} */ (document.getElementById(ID));
if (e) {
return e;
}
e = ElementCreate({
tag: "input",
attributes: {
id: ID,
name: ID,
type: Type,
value: Value,
maxLength: typeof MaxLength === "number" ? MaxLength : Number.parseInt(MaxLength, 10),
["screen-generated"]: CurrentScreen,
},
parent: form ?? document.body,
classList: ["HideOnPopup"],
eventListeners: {
focus() { this.removeAttribute("readonly"); },
},
});
switch (Type) {
case "number":
e.inputMode = "numeric";
e.addEventListener("blur", ElementNumberInputBlur);
e.addEventListener("wheel", ElementNumberInputWheel);
break;
}
return e;
}
/**
* Creates a new range input element in the main document. Does not create a new element if there is already an
* existing one with the same id
* @param {string} id - The id of the input tag to create
* @param {number} value - The initial value of the input
* @param {number} min - The minimum value of the input
* @param {number} max - The maximum value of the input
* @param {number} step - The increment size of the input
* @param {ThumbIcon} [thumbIcon] - The icon to use for the range input's "thumb" (handle). If not set, the slider will
* have a default appearance with no custom thumb.
* @param {boolean} [vertical] - Whether this range input is a vertical slider (defaults to false)
* @returns {HTMLInputElement} - The created HTML input element
*/
function ElementCreateRangeInput(id, value, min, max, step, thumbIcon, vertical) {
return /** @type {HTMLInputElement} */ (document.getElementById(id)) ?? ElementCreate({
tag: "input",
attributes: {
id,
name: id,
type: "range",
min: min.toString(),
max: max.toString(),
step: step.toString(),
value: value.toString(),
["screen-generated"]: CurrentScreen,
},
dataAttributes: thumbIcon ? { thumb: thumbIcon.toLowerCase() } : {},
parent: document.body,
classList: [
"HideOnPopup",
"range-input",
vertical ? "Vertical" : null,
],
eventListeners: {
focus() { this.removeAttribute("readonly"); },
},
});
}
/**
* Construct a `<select>`-based dropdown menu.
* @param {string} id - The name of the select item.
* @param {readonly (string | Omit<HTMLOptions<"option">, "tag">)[]} optionsList - The list of options for the current select statement. Can be supplied as a simple string or a more extensive `<option>` config.
* @param {(this: HTMLSelectElement, event: Event) => any} onChange - An event listener to be called, when the value of the drop down box changes
* @param {null | { required?: boolean, multiple?: boolean, disabled?: boolean, size?: number }} [options] - Additional `<select>`-specific properties
* @param {null | Partial<Record<"select", Omit<HTMLOptions<"select">, "tag">>>} htmlOptions - Additional {@link ElementCreate} options to-be applied to the respective (child) element
* @returns {HTMLSelectElement} - The created element
*/
function ElementCreateDropdown(id, optionsList, onChange, options=null, htmlOptions=null) {
let select = /** @type {null | HTMLSelectElement} */(document.getElementById(id));
if (select != null) {
console.error(`Element "${id}" already exists`);
return select;
}
options ??= {};
const booleanAttributes = Object.fromEntries(/** @type {const} */([
["required", options.required ? "" : undefined],
["multiple", options.multiple ? "" : undefined],
["disabled", options.disabled ? "" : undefined],
["size", options.size != null ? options.size.toString() : undefined],
]).filter(i => i[1] != null));
const selectOptions = htmlOptions?.select ?? {};
select = ElementCreate({
...htmlOptions,
tag: "select",
classList: [
...(selectOptions.classList ?? []),
"HideOnPopup",
"custom-select",
],
attributes: {
...(selectOptions.attributes ?? {}),
id,
["screen-generated"]: CurrentScreen,
...booleanAttributes,
},
parent: selectOptions.parent ?? document.body,
eventListeners: {
...(selectOptions.eventListeners ?? {}),
change(ev) {
if (!this.validity) {
ev.stopImmediatePropagation();
return;
}
},
},
children: [
...(selectOptions.children ?? []),
...optionsList.map(content => {
if (typeof content === "string") {
return { tag: /** @type {const} */("option"), children: [content] };
} else {
return { tag: /** @type {const} */("option"), ...content };
}
}),
],
});
select.addEventListener("change", onChange);
return select;
}
/**
* Creates a new div element in the main document. Does not create a new element if there is already an existing one with the same ID
* @param {string} ID - The id of the div tag to create.
* @returns {HTMLDivElement} - The created (or pre-existing) div element
*/
function ElementCreateDiv(ID) {
return /** @type {HTMLDivElement} */(document.getElementById(ID)) ?? ElementCreate({
tag: "div",
attributes: {
id: ID,
["screen-generated"]: CurrentScreen,
},
parent: document.body,
classList: ["HideOnPopup"],
});
}
/**
* Removes an element from the main document
* @param {string} ID - The id of the tag to remove from the document.
* @returns {void} - Nothing
*/
function ElementRemove(ID) {
if (document.getElementById(ID) != null)
document.getElementById(ID).parentNode.removeChild(document.getElementById(ID));
}
/**
* Draws an existing HTML element at a specific position within the document. The element is "centered" on the given coordinates by dividing its height and width by two.
* @param {string | HTMLElement} ElementOrID - The id of the input tag to (re-)position.
* @param {number} X - Center point of the element on the X axis.
* @param {number} Y - Center point of the element on the Y axis.
* @param {number} W - Width of the element.
* @param {number} [H] - Height of the element.
* @returns {void} - Nothing
*/
function ElementPosition(ElementOrID, X, Y, W, H) {
const E = typeof ElementOrID === "string" ? document.getElementById(ElementOrID) : ElementOrID;
if (!E) {
console.warn("A call to ElementPosition was made on non-existent element with ID '" + ElementOrID + "'");
return;
}
// For a vertical slider, swap the width and the height (the transformation is handled by CSS)
if (E.tagName.toLowerCase() === "input" && E.getAttribute("type") === "range" && E.classList.contains("Vertical")) {
var tmp = W;
W = H;
H = tmp;
}
// Different positions based on the width/height ratio
const HRatio = MainCanvas.canvas.clientHeight / 1000;
const WRatio = MainCanvas.canvas.clientWidth / 2000;
const Font = MainCanvas.canvas.clientWidth <= MainCanvas.canvas.clientHeight * 2 ? MainCanvas.canvas.clientWidth / 50 : MainCanvas.canvas.clientHeight / 25;
const Height = H ? H * HRatio : 4 + Font * 1.15;
const Width = W * WRatio;
const Top = MainCanvas.canvas.offsetTop + Y * HRatio - Height / 2;
const Left = MainCanvas.canvas.offsetLeft + (X - W / 2) * WRatio;
// Sets the element style
Object.assign(E.style, {
fontSize: Font + "px",
fontFamily: CommonGetFontName(),
position: "fixed",
left: Left + "px",
top: Top + "px",
width: Width + "px",
height: Height + "px",
});
}
/**
* Draws an existing HTML element at a specific position within the document. The element will not be centered on its given coordinates unlike the ElementPosition function.
* Not same as ElementPositionFix. Calculates Font size itself.
* @param {string | HTMLElement} ElementOrID - The id of the input tag to (re-)position or the element itself.
* @param {number} X - Starting point of the element on the X axis.
* @param {number} Y - Starting point of the element on the Y axis.
* @param {number} W - Width of the element.
* @param {number} [H] - Height of the element.
* @returns {void} - Nothing
*/
function ElementPositionFixed(ElementOrID, X, Y, W, H) {
const E = typeof ElementOrID === "string" ? document.getElementById(ElementOrID) : ElementOrID;
// Verify the element exists
if (!E) {
const id = typeof ElementOrID === "string" ? ElementOrID : ElementOrID?.id;
console.warn(`A call to ElementPositionFix was made on non-existent element with ID "${id}"`);
return;
}
// Different positions based on the width/height ratio
const HRatio = MainCanvas.canvas.clientHeight / 1000;
const WRatio = MainCanvas.canvas.clientWidth / 2000;
const Font = MainCanvas.canvas.clientWidth <= MainCanvas.canvas.clientHeight * 2 ? MainCanvas.canvas.clientWidth / 50 : MainCanvas.canvas.clientHeight / 25;
const Top = MainCanvas.canvas.offsetTop + Y * HRatio;
const Height = H ? H * HRatio : Font * 1.15;
const Left = MainCanvas.canvas.offsetLeft + X * WRatio;
const Width = W * WRatio;
// Sets the element style
Object.assign(E.style, {
fontSize: Font + "px",
fontFamily: CommonGetFontName(),
position: "fixed",
left: Left + "px",
top: Top + "px",
width: Width + "px",
height: Height + "px",
});
}
/**
* Draws an existing HTML element at a specific position within the document. The element will not be centered on its given coordinates unlike the ElementPosition function.
* @param {string} ElementID - The id of the input tag to (re-)position.
* @param {number} Font - The size of the font to use.
* @param {number} X - Starting point of the element on the X axis.
* @param {number} Y - Starting point of the element on the Y axis.
* @param {number} W - Width of the element.
* @param {number} H - Height of the element.
* @returns {void} - Nothing
*/
function ElementPositionFix(ElementID, Font, X, Y, W, H) {
var E = document.getElementById(ElementID);
// Verify the element exists
if (!E) {
console.warn("A call to ElementPositionFix was made on non-existent element with ID '" + ElementID + "'");
return;
}
// Different positions based on the width/height ratio
const HRatio = MainCanvas.canvas.clientHeight / 1000;
const WRatio = MainCanvas.canvas.clientWidth / 2000;
Font *= Math.max(HRatio, WRatio);
const Top = MainCanvas.canvas.offsetTop + Y * HRatio;
const Height = H * HRatio;
const Left = MainCanvas.canvas.offsetLeft + X * WRatio;
const Width = W * WRatio;
// Sets the element style
Object.assign(E.style, {
fontSize: Font + "px",
fontFamily: CommonGetFontName(),
position: "fixed",
left: Left + "px",
top: Top + "px",
width: Width + "px",
height: Height + "px",
});
}
/**
* Sets a custom data-attribute to a specified value on a specified element
* @param {string} ID - The id of the element to create/set the data attribute of.
* @param {string} Name - Name of the data attribute. ("data-" will be automatically appended to it.)
* @param {string} Value - Value to give to the attribute.
* @returns {void} - Nothing
*/
function ElementSetDataAttribute(ID, Name, Value) {
var element = document.getElementById(ID);
if (element != null) {
element.setAttribute(("data-" + Name).toLowerCase(), Value.toString().toLowerCase());
}
}
/**
* Sets an attribute to a specified value on a specified element
* @param {string} ID - The id of the element to create/set the data attribute of.
* @param {string} Name - Name of the attribute.
* @param {string} Value - Value to give to the attribute.
* @returns {void} - Nothing
*/
function ElementSetAttribute(ID, Name, Value) {
var element = document.getElementById(ID);
if (element != null) {
element.setAttribute(Name, Value);
}
}
/**
* Removes an attribute from a specified element.
* @param {string} ID - The id of the element from which to remove the attribute.
* @param {string} Name - Name of the attribute to remove.
* @returns {void} - Nothing
*/
function ElementRemoveAttribute(ID, Name) {
var element = document.getElementById(ID);
if (element != null) {
element.removeAttribute(Name);
}
}
/**
* Scrolls to the end of a specified element
* @param {string} ID - The id of the element to scroll down to the bottom of.
* @returns {void} - Nothing
*/
function ElementScrollToEnd(ID) {
var element = document.getElementById(ID);
if (element != null) element.scrollTop = element.scrollHeight;
}
/**
* Returns the given element's scroll position as a percentage, with the top of the element being close to 0 depending on scroll bar size, and the bottom being around 1.
* To clarify, this is the position of the bottom edge of the scroll bar.
* @param {string} ID - The id of the element to find the scroll percentage of.
* @returns {(number|null)} - A float representing the scroll percentage.
*/
function ElementGetScrollPercentage(ID) {
var element = document.getElementById(ID);
if (element != null) {
if (element.scrollTop === 0) return 0;
return (element.scrollTop + element.clientHeight) / element.scrollHeight;
}
return null;
}
/**
* Checks if a given HTML element is scrolled to the very bottom.
* @param {string} ID - The id of the element to check for scroll height.
* @returns {boolean} - Returns TRUE if the specified element is scrolled to the very bottom
*/
function ElementIsScrolledToEnd(ID) {
var element = document.getElementById(ID);
return element != null && element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
}
/**
* Sets the scroll position of an element to a specified percentage of its scrollable content.
* Ideally scroll percentage should be gotten with {@link ElementGetScrollPercentage}
*
* @param {string} ID
* @param {number} scrollPercentage
* @param {ScrollBehavior} scrollBehavior
* @returns {void}
*/
function ElementSetScrollPercentage(ID, scrollPercentage, scrollBehavior = 'auto') {
const element = document.getElementById(ID);
if (!element) {
console.error(`Element with ID "${ID}" not found.`);
return;
}
if (scrollPercentage < 0 || scrollPercentage > 1) {
console.error("scrollPercentage must be between 0 and 1 (inclusive).");
return;
}
const scrollHeight = element.scrollHeight;
const clientHeight = element.clientHeight;
const newScrollTop = Math.max(0, (scrollPercentage * scrollHeight - clientHeight)); // Clamp to 0 for valid range
element.scrollTo({
top: newScrollTop,
behavior: scrollBehavior
});
}
/**
* Gives focus to a specified existing element for non-mobile users.
* @param {string} ID - The id of the element to give focus to.
* @returns {void} - Nothing
*/
function ElementFocus(ID) {
if ((document.getElementById(ID) != null) && !CommonIsMobile)
document.getElementById(ID).focus();
}
/**
* Toggles (non-nested) HTML elements that were created by a given screen. When toggled off, they are hidden (not removed)
* @param {string} Screen - Screen for which to hide the elements generated
* @param {boolean} ShouldDisplay - TRUE if we are toggling on the elements, FALSE if we are hiding them.
*/
function ElementToggleGeneratedElements(Screen, ShouldDisplay) {
const displayState = ShouldDisplay ? "" : "none";
const elements = /** @type {HTMLElement[]} */(Array.from(document.querySelectorAll(`[screen-generated="${Screen}"]`)));
for (const e of elements) {
if (e.parentElement === null || e.parentElement === document.body) {
e.style.display = displayState;
}
}
}
/**
* Namespace for creating (DOM-based) dropdown menus filled with checkboxes
* @namespace
*/
var ElementCheckboxDropdown = {
/**
* @param {string} idPrefix
* @param {string} idSuffix
* @param {string} spanText
* @param {(this: HTMLInputElement, event: Event) => void} listener
* @param {boolean} checked
* @returns {HTMLOptions<"label">}
*/
_CreateCheckboxPair(idPrefix, idSuffix, spanText, listener, checked=false) {
return {
tag: "label",
classList: ["dropdown-checkbox-grid"],
attributes: { id: `${idPrefix}-pair-${idSuffix}` },
children: [
ElementCheckbox.Create(`${idPrefix}-checkbox-${idSuffix}`, listener, { checked }),
{
tag: "span",
classList: ["dropdown-checkbox-label"],
attributes: { id: `${idPrefix}-label-${idSuffix}` },
children: [spanText],
},
],
};
},
/**
* Construct a dropdown menu with labeled checkboxes
* @param {string} id - The ID of the element
* @param {readonly string[]} checkboxList - The checkbox labels
* @param {(this: HTMLInputElement, event: Event) => void} eventListener - The event listener to-be attached to all checkboxes
* @param {Object} [options]
* @param {HTMLElement} [options.parent] - The parent element of the dropdown menu; defaults to {@link document.body}
* @param {boolean} [options.checked] - Whether all checkboxes should be initially checked
* @returns {HTMLDivElement} - The created dropdown menu
*/
FromList(id, checkboxList, eventListener, options=null) {
return /** @type {null | HTMLDivElement} */(document.getElementById(id)) ?? ElementCreate({
tag: "div",
attributes: { id, ["screen-generated"]: CurrentScreen },
parent: options?.parent ?? document.body,
classList: ["HideOnPopup", "dropdown", "scroll-box"],
style: { display: "none" },
children: checkboxList.map((o) => this._CreateCheckboxPair(id, o, o, eventListener, options?.checked)),
});
},
/**
* Construct a dropdown menu with labeled checkboxes, each group of checkboxes having a header associated with them
* @param {string} id - The ID of the element
* @param {Record<string, readonly string[]>} checkboxRecord - The checkbox labels
* @param {(this: HTMLInputElement, event: Event) => void} eventListener - The event listener to-be attached to all checkboxes
* @param {Object} [options]
* @param {HTMLElement} [options.parent] - The parent element of the dropdown menu; defaults to {@link document.body}
* @param {boolean} [options.checked] - Whether all checkboxes should be initially checked
* @returns {HTMLDivElement} - The created dropdown menu
*/
FromRecord(id, checkboxRecord, eventListener, options=null) {
return /** @type {null | HTMLDivElement} */(document.getElementById(id)) ?? ElementCreate({
tag: "div",
attributes: { id, ["screen-generated"]: CurrentScreen },
parent: options?.parent ?? document.body,
classList: ["HideOnPopup", "dropdown", "scroll-box"],
style: { display: "none" },
children: Object.entries(checkboxRecord).flatMap(([header, checkboxList]) => {
return [
{
tag: "span",
classList: ["dropdown-header"],
attributes: { id: `${id}-header-${header}` },
children: [header],
},
{
tag: "div",
classList: ["dropdown-grid"],
attributes: { id: `${id}-grid-${header}` },
children: checkboxList.map((o) => this._CreateCheckboxPair(id, `${header}-${o}`, o, eventListener, options?.checked)),
},
];
}),
});
},
};
/**
* Construct a search-based `<input>` element that offers suggestions based on the passed callbacks output.
*
* The search suggestions are constructed lazily once the search input is focused.
* @example
* <input type="search" id={id} list={`${id}-datalist`}>
* <datalist id={`${id}-datalist`}>
* <option value="..." />
* ...
* </datalist>
* </input>
* @param {string} id - The ID of the to-be created search input; `${id}-datalist` will be assigned the search input's datalist
* @param {() => Iterable<string>} dataCallback - A callback returning all values that will be converted into a datalist `<option>`
* @param {Object} [options]
* @param {null | string} [options.value] - Value of the search input
* @param {null | Node} [options.parent] - The parent element of the search input; defaults to {@link document.body}
* @param {null | number} [options.maxLength] - Maximum input length of the search input
* @returns {HTMLInputElement} - The newly created search input
*/
function ElementCreateSearchInput(id, dataCallback, options=null) {
let elem = /** @type {HTMLInputElement | null} */(document.getElementById(id));
if (elem) {
console.error(`Element "${id}" already exists`);
return elem;
}
options ??= {};
elem = ElementCreateInput(id, "search", options.value ?? "", options.maxLength, options.parent);
elem.appendChild(ElementCreate({ tag: "datalist", attributes: { id: `${id}-datalist` } }));
elem.setAttribute("list", `${id}-datalist`);
elem.addEventListener("focus", async function() {
if (this.list?.children.length !== 0) {
return;
}
for (const value of dataCallback()) {
this.list.appendChild(ElementCreate({ tag: "option", attributes: { value } }));
}
});
return elem;
}
/**
* Namespace for creating HTML buttons
* @namespace
*/
var ElementButton = {
/**
* A unique element ID-suffix to-be assigned to buttons without an explicit ID.
* @private
*/
_idCounter: 0,
/**
* @private
* @readonly
*/
_TooltipPositions: Object.freeze({
left: "button-tooltip-left",
right: "button-tooltip-right",
top: "button-tooltip-top",
bottom: "button-tooltip-bottom",
}),
/**
* @private
* @readonly
*/
_LabelPositions: Object.freeze({
top: "button-label-top",
center: "button-label-center",
bottom: "button-label-bottom",
}),
/**
* @private
* @type {(this: HTMLButtonElement, ev: KeyboardEvent) => Promise<void>}
*/
_KeyDown: async function _KeyDown(ev) {
if (CommonKey.GetModifiers(ev)) {
return;
}
switch (ev.key) {
case "Enter":
case " ":
ev.preventDefault();
if (this.disabled || this.getAttribute("aria-disabled") === "true") {
ev.stopImmediatePropagation();
return;
} else if (!ev.repeat) {
this.click();
this.setAttribute("data-active", true);
}
ev.stopPropagation();
break;
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: KeyboardEvent) => Promise<void>}
*/
_KeyUp: async function _KeyUp(ev) {
if (ev.shiftKey || ev.ctrlKey || ev.metaKey || ev.altKey) {
return;
}
switch (ev.key) {
case "Enter":
case " ":
if (this.disabled || this.getAttribute("aria-disabled") === "true") {
ev.stopImmediatePropagation();
return;
}
this.removeAttribute("data-active");
break;
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: MouseEvent | TouchEvent) => void}
*/
_Click: function _Click(ev) {
if (ev.target instanceof Element && ev.target.classList.contains("button-tooltip")) {
ev.stopImmediatePropagation();
} else if (this.getAttribute("aria-disabled") === "true") {
this.dispatchEvent(new MouseEvent("bcClickDisabled", ev));
ev.stopImmediatePropagation();
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_MouseDown: function _MouseDown(ev) {
// Prevent tooltips from handling any click-related actions intended for the button
if (ev.target instanceof Element && ev.target.classList.contains("button-tooltip")) {
ev.stopImmediatePropagation();
ev.preventDefault();
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_MouseUp: function _MouseUp(ev) {
if (ev.target instanceof Element && ev.target.classList.contains("button-tooltip")) {
ev.stopImmediatePropagation();
return;
}
// Fix buttons not automatically losing focus after a click event
this.blur();
},
/**
* Navigate the passed elements children in a depth-first search manner,
* yielding all elements matching the `query` selector and whose parent does _not_ satisify the passed `filter`
* @param {Element} root
* @param {string} query
* @param {(el: Element) => boolean} filter
* @returns {Generator<Element, void>}
*/
_QueryDFS: function *_QueryDFS(root, query, filter) {
for (const elem of root.children) {
if (elem.matches(query)) {
yield elem;
}
if (filter(elem)) {
continue;
} else {
yield *ElementButton._QueryDFS(elem, query, filter);
}
}
},
/**
* Click event listener for radio buttons.
* @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/radio_role
* @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/menuitemradio_role
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickRadio: function _ClickRadio(ev) {
// Take precaution against nested radio groups/menus, as one might accidentally query a sibbling that belongs to a different group
// Particularly important for menus embedded in other menus or menubars
const role = this.getAttribute("role");
const isRadio = role === "radio";
const parent = this.parentElement?.closest(isRadio ? "[role='radiogroup']" : "[role='menu'], [role='menubar']");
if (!parent) {
return;
}
// Ensure that `radio` buttons to switch the tabindex of the active radio to 0, while `menuitemradio` buttons do not
if (this.getAttribute("aria-checked") === "true") {
if (parent.getAttribute("aria-required") === "true") {
ev.stopImmediatePropagation();
} else {
this.setAttribute("aria-checked", "false");
if (isRadio) {
/** @type {(e: Element) => boolean} */
const filter = (e) => e.getAttribute("role") === "radiogroup" || !ElementCheckVisibility(e);
const first = ElementButton._QueryDFS(parent, `[role='${role}']`, filter).next();
if (first.value) {
this.tabIndex = -1;
first.value.setAttribute("tabindex", "0");
}
}
if (this.getAttribute("aria-expanded") === "true") {
this.setAttribute("aria-expanded", "false");
}
}
} else {
/** @type {(e: Element) => boolean} */
const filter = isRadio
? (e) => e.getAttribute("role") === "radiogroup"
: (e) => e.getAttribute("role") === "menu" || e.getAttribute("role") === "menuitem";
let prev = ElementButton._QueryDFS(parent, `[role='${role}'][aria-checked='true']`, filter).next();
if (!prev.value && isRadio) {
prev = ElementButton._QueryDFS(parent, `[role='${role}'][tabindex='0']`, filter).next();
}
if (prev.value) {
prev.value.setAttribute("aria-checked", "false");
if (prev.value.getAttribute("aria-expanded") === "true") {
prev.value.setAttribute("aria-expanded", "false");
}
if (isRadio) {
prev.value.setAttribute("tabindex", "-1");
}
}
if (this.getAttribute("aria-expanded") === "false") {
this.setAttribute("aria-expanded", "true");
}
if (isRadio) {
this.tabIndex = 0;
}
this.setAttribute("aria-checked", "true");
}
},
/**
* @this {HTMLElement}
* @param {KeyboardEvent} ev
*/
_KeyDownRadio: function _KeyDownRadio(ev) {
if (CommonKey.GetModifiers(ev)) {
return;
}
switch (ev.key) {
case "ArrowRight":
case "ArrowDown":
case "ArrowLeft":
case "ArrowUp": {
const sibblings = Array.from(this.closest("[role='radiogroup']")?.querySelectorAll("button[role='radio']") ?? []).filter(e => ElementCheckVisibility(e));
const thisIdx = sibblings.indexOf(this);
if (thisIdx === -1) {
return;
}
/** @type {Element} */
let next = this;
const nSibblings = sibblings.length;
if (ev.key === "ArrowDown" || ev.key === "ArrowRight") {
next = sibblings[(thisIdx + 1) % nSibblings];
} else {
next = thisIdx === 0 ? sibblings[nSibblings - 1] : sibblings[(thisIdx - 1) % nSibblings];
}
if (next !== this) {
/** @type {HTMLButtonElement} */(next).click();
/** @type {HTMLButtonElement} */(next).focus();
ev.stopPropagation();
ev.preventDefault();
}
break;
}
}
},
/**
* Click event listener for checkbox buttons.
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickCheckbox: function _ClickCheckbox(ev) {
if (this.getAttribute("aria-checked") === "true") {
if (this.getAttribute("aria-expanded") === "true") {
this.setAttribute("aria-expanded", "false");
}
this.setAttribute("aria-checked", "false");
} else {
if (this.getAttribute("aria-expanded") === "false") {
this.setAttribute("aria-expanded", "true");
}
this.setAttribute("aria-checked", "true");
}
},
/**
* @private
* @param {string} id
* @param {string} [img]
* @param {Omit<HTMLOptions<"img">, "tag">} [options]
* @returns {HTMLImageElement}
*/
_ParseImage: function _ParseImage(id, img, options) {
if (!img) {
return;
}
options ??= {};
return ElementCreate({
...options,
tag: "img",
classList: ["button-image", ...(options.classList ?? [])],
attributes: { id: `${id}-image`, decoding: "async", loading: "lazy", "aria-hidden": "true", src: img, ...(options.attributes ?? {}) },
});
},
/**
* @private
* @param {string} id
* @param {ElementButton.StaticNode} [label]
* @param {"top" | "center" | "bottom"} [position]
* @param {Omit<HTMLOptions<"span">, "tag">} [options]
* @returns {HTMLSpanElement}
*/
_ParseLabel: function _ParseLabel(id, label, position, options) {
label = (CommonIsArray(label) ? label : [label]).filter(i => i != null);
if (label.length === 0) {
return;
}
options ??= {};
const labelPosition = this._LabelPositions[position] ?? this._LabelPositions.bottom;
return ElementCreate({
...options,
tag: "span",
attributes: { id: `${id}-label`, for: id, ...(options.attributes ?? {}) },
classList: ["button-label", labelPosition, ...(options.classList ?? [])],
children: [...label, ...(options.children ?? [])],
});
},
/**
* Parse the passed icon list, returning its corresponding `<img>` grid and tooltip if non-empty
* @param {string} id - The ID of the parent element
* @param {readonly (InventoryIcon | ElementButton.CustomIcon)[]} [icons] - The (optional) list of icons
* @returns {null | { iconGrid: HTMLDivElement, tooltip: [string, HTMLElement] }} - `null` if the provided icon list is empty and otherwise an object containing the icon grid and a icon-specific tooltip
*/
_ParseIcons: function _ParseIcons(id, icons) {
icons = icons?.filter(i => i != null);
if (!icons || icons.length === 0) {
return null;
}
const tooltip = document.getElementById(`${id}-icon-ul`) ?? ElementCreate({
tag: "ul",
attributes: { id: `${id}-icon-ul` },
classList: ["button-icon-tooltip-ul"],
children: [],
});
const iconGrid = /** @type {HTMLDivElement} */(document.getElementById(`${id}-icon-grid`)) ?? ElementCreate({
tag: "div",
classList: ["button-icon-grid"],
attributes: { id: `${id}-icon-grid`, "aria-hidden": "true" },
});
const iconNames = Array.from(iconGrid.querySelectorAll(".button-icon")).map(el => el.getAttribute("data-name"));
icons.forEach((icon) => {
let custom = false;
/** @type {string} */
let name;
/** @type {string} */
let src;
/** @type {(string | Node | HTMLOptions<any>)[]} */
let tooltipChildren;
if (typeof icon === "object") {
custom = true;
name = icon.name;
src = icon.iconSrc;
tooltipChildren = (CommonIsArray(icon.tooltipText) ? icon.tooltipText : [icon.tooltipText]).filter(i => i != null);
} else if (icon.endsWith("Padlock")) {
name = icon;
src = `./Assets/Female3DCG/ItemMisc/Preview/${icon}.png`;
tooltipChildren = [InterfaceTextGet("PreviewIconPadlock").replace(
"AssetName",
AssetGet("Female3DCG", "ItemMisc", icon).Description,
)];
} else {
name = icon;
src = `./Icons/Previews/${icon}.png`;
tooltipChildren = [InterfaceTextGet(`PreviewIcon${icon}`)];
}
if (iconNames.includes(name)) {
return;
}
ElementCreate({
tag: "li",
attributes: { id: `${id}-icon-li-${name}` },
classList: ["button-icon-tooltip-li"],
children: tooltipChildren,
parent: tooltip,
style: { "background-image": src.startsWith("data:image") ? `url("${src}")` : `url("./${src}")` },
});
ElementCreate({
tag: "img",
classList: ["button-icon"],
attributes: { decoding: "async", loading: "lazy", src, "aria-owns": `${id}-icon-li-${name}` },
dataAttributes: { name, custom: custom ? "" : undefined },
parent: iconGrid,
});
});
return { iconGrid, tooltip: [InterfaceTextGet("StatusAndEffects"), tooltip] };
},
/**
* @private
* @param {string} id
* @param {"left" | "right" | "top" | "bottom"} [position]
* @param {readonly (null | string | Node | HTMLOptions<any>)[]} [children]
* @param {Omit<HTMLOptions<"div">, "tag">} [options]
* @returns {null | HTMLDivElement}
*/
_ParseTooltip: function _ParseTooltip(id, position, children, options) {
if (!children || children.every(i => i == null)) {
return null;
}
options ??= {};
const tooltipPosition = this._TooltipPositions[position] ?? this._TooltipPositions.left;
return ElementCreate({
...options,
tag: "div",
classList: ["button-tooltip", tooltipPosition, ...(options.classList ?? [])],
attributes: {
id: `${id}-tooltip`,
role: "tooltip",
...(options.attributes ?? {}),
},
children,
});
},
/**
* Create a generic button.
* @param {null | string} id - The ID of the to-be created search button
* @param {(this: HTMLButtonElement, ev: MouseEvent | TouchEvent) => any} onClick - The click event listener to-be attached to the tooltip
* @param {null | ElementButton.Options} [options] - High level options for the to-be created button
* @param {null | Partial<Record<"button" | "tooltip" | "img" | "label", Omit<HTMLOptions<any>, "tag">>>} htmlOptions - Additional low-level {@link ElementCreate} options to-be applied to the either the button or tooltip
* @returns {HTMLButtonElement} - The created button
*/
Create: function Create(id, onClick, options=null, htmlOptions=null) {
id ??= `button-${ElementButton._idCounter++}`;
let elem = /** @type {HTMLButtonElement | null} */(document.getElementById(id));
if (elem) {
console.error(`Element "${id}" already exists`);
return elem;
}
htmlOptions ??= {};
const buttonOptions = htmlOptions.button ?? {};
const tooltipOptions = htmlOptions.tooltip ?? {};
options ??= {};
const image = this._ParseImage(id, options.image, htmlOptions.img);
const label = this._ParseLabel(id, options.label, options.labelPosition, htmlOptions.label);
const icons = this._ParseIcons(id, options.icons);
// Only add the icon-based component of the tooltip if there is an actual tooltip
/** @type {(null | string | Node | HTMLOptions<any>)[]} */
const protoTooltip = [...(CommonIsArray(options.tooltip) ? options.tooltip : [options.tooltip])];
if (!protoTooltip.every(i => i == null)) {
protoTooltip.push(...(icons?.tooltip ?? []));
}
protoTooltip.push(...(tooltipOptions.children ?? []));
const tooltip = this._ParseTooltip(id, options.tooltipPosition, protoTooltip, tooltipOptions);
/** @type {null | "aria-labelledby" | "aria-describedby"} */
let tooltipRoleAttribute = null;
switch (options.tooltipRole ?? null) {
case "label":
tooltipRoleAttribute = "aria-labelledby";
break;
case "description":
tooltipRoleAttribute = "aria-describedby";
break;
case null:
tooltipRoleAttribute = (label ? "aria-describedby" : "aria-labelledby");
break;
}
elem = ElementCreate({
...buttonOptions,
tag: "button",
attributes: {
id,
name: id,
[tooltipRoleAttribute]: tooltip ? `${id}-tooltip` : undefined,
"screen-generated": CurrentScreen,
role: options.role,
...(buttonOptions.attributes ?? {}),
},
classList: ["blank-button", "button", options.noStyling ? null : "button-styling", "HideOnPopup", ...(buttonOptions.classList ?? [])],
eventListeners: {
click: this._Click,
keydown: this._KeyDown,
keyup: this._KeyUp,
mouseup: this._MouseUp,
mousedown: this._MouseDown,
touchend: this._MouseUp,
touchcancel: this._MouseUp,
bcClickDisabled: options.clickDisabled,
...(buttonOptions.eventListeners ?? {}),
},
children: [
tooltip,
image,
icons?.iconGrid,
label,
...(buttonOptions.children ?? []),
],
});
const role = buttonOptions.attributes?.role ?? options.role;
switch (role) {
case "radio":
case "menuitemradio":
elem.addEventListener("click", this._ClickRadio);
if (!elem.getAttribute("aria-checked")) {
elem.setAttribute("aria-checked", "false");
}
if (role === "radio") {
elem.addEventListener("keydown", this._KeyDownRadio);
if (elem.getAttribute("tabindex") == null) {
elem.tabIndex = elem.getAttribute("aria-checked") === "true" ? 0 : -1;
}
}
break;
case "checkbox":
case "menuitemcheckbox":
elem.addEventListener("click", this._ClickCheckbox);
if (!elem.getAttribute("aria-checked")) {
elem.setAttribute("aria-checked", "false");
}
break;
}
elem.addEventListener("click", onClick);
if (options.disabled) {
const menuItemRoles = ["menuitem", "menuitemradio", "menuitemcheckbox"];
if (menuItemRoles.some(i => elem.getAttribute("role") === i)) {
elem.setAttribute("aria-disabled", true);
} else {
elem.disabled = true;
}
}
return elem;
},
/**
* Create a button for an asset or item, including image, label and icons.
* @param {string} idPrefix - The ID of the to-be created search button
* @param {Asset | Item} asset - The asset (or item) for which to create a button
* @param {null | Character} C - The character wearing the asset/item (if any)
* @param {(this: HTMLButtonElement, ev: MouseEvent | TouchEvent) => any} onClick - The click event listener to-be attached to the tooltip
* @param {null | ElementButton.Options} [options] - High level options for the to-be created button
* @param {null | Partial<Record<"button" | "tooltip" | "img" | "label", Omit<HTMLOptions<any>, "tag">>>} htmlOptions - Additional low-level {@link ElementCreate} options to-be applied to the either the button or tooltip
* @returns {HTMLButtonElement} - The created button
*/
CreateForAsset: function CreateForAsset(idPrefix, asset, C, onClick, options=null, htmlOptions=null) {
const item = "Asset" in asset ? asset : { Asset: asset };
asset = item.Asset;
const id = `${idPrefix}-${asset.Group.Name}-${asset.Name}`;
const elem = /** @type {HTMLButtonElement | null} */(document.getElementById(id));
if (elem) {
console.error(`Element "${id}" already exists`);
return elem;
}
/** @type {null | TextCache} */
let craftCache = null;
if (item.Craft) {
craftCache = TextPrefetchFile("Screens/Room/Crafting/Text_Crafting.csv");
}
htmlOptions ??= {};
htmlOptions.button ??= {};
htmlOptions.button.attributes ??= {};
htmlOptions.button.attributes.name ??= asset.Name;
htmlOptions.button.dataAttributes ??= {};
htmlOptions.button.dataAttributes.group ??= asset.Group.Name;
htmlOptions.button.dataAttributes.craft ??= item.Craft ? "" : undefined;
htmlOptions.button.dataAttributes.hidden ??= CharacterAppearanceItemIsHidden(asset.Name, asset.Group.Name) ? "" : undefined;
htmlOptions.button.dataAttributes.vibrating ??= item.Property?.Effect?.includes("Vibrating") ? "" : undefined;
htmlOptions.button.children = [
...(htmlOptions.button.children ?? []),
ElementButton._ParseImage(`${idPrefix}-hidden`, "./Icons/HiddenItem.png", { dataAttributes: { hidden: "" } }),
];
htmlOptions.tooltip ??= {};
htmlOptions.tooltip.classList ??= [];
htmlOptions.tooltip.classList = [
...htmlOptions.tooltip.classList,
"button-tooltip-justify",
];
options ??= {};
options.label ??= item.Craft?.Name || asset.Description;
options.image ??= `./Assets/Female3DCG/${asset.DynamicGroupName}/Preview/${asset.Name}.png`;
options.icons = [
...(options.icons ?? []),
DialogGetFavoriteStateDetails(C ?? Player, asset)?.Icon,
InventoryBlockedOrLimited(C ?? Player, item) ? "Blocked" : null,
InventoryIsAllowedLimited(C ?? Player, item) ? "AllowedLimited" : null,
...DialogGetLockIcon(item, "Property" in item),
...DialogGetAssetIcons(asset),
...DialogEffectIcons.GetIcons(item),
];
options.tooltipPosition ??= "bottom";
options.tooltip ??= !item.Craft ? "" : /** @type {HTMLOptions<any>[]} */([
...(CommonIsArray(options.tooltip) ? options.tooltip : [options.tooltip]),
{
tag: "span",
children: [InterfaceTextGet("DialogMenuCrafting") + ":"],
},
{
tag: "ul",
classList: ["button-tooltip-craft"],
children: [
item.Craft.Property ? {
tag: "li",
children: [
InterfaceTextGet("CraftingProperty").replace("CraftProperty", ""),
{
tag: "q",
children: [{ tag: "dfn", children: [item.Craft.Property] }, " - "],
},
],
} : undefined,
(item.Craft.MemberName && item.Craft.MemberNumber) ? {
tag: "li",
children: [
InterfaceTextGet("CraftingMember").replace("MemberName (MemberNumber)", ""),
{ tag: "q", children: [`${item.Craft.MemberName} (${item.Craft.MemberNumber})`] },
],
} : undefined,
{
tag: "li",
children: [
InterfaceTextGet("CraftingPrivate").replace("CraftPrivate", ""),
{ tag: "q", children: [CommonCapitalize(item.Craft.Private.toString())] },
],
},
item.Craft.Description ? {
tag: "li",
children: [
InterfaceTextGet("CraftingDescription").replace("CraftDescription", ""),
{ tag: "q", children: CraftingDescription.DecodeToHTML(item.Craft.Description) },
],
} : undefined,
],
},
]);
const button = ElementButton.Create(id, onClick, options, htmlOptions);
craftCache?.loadedPromise.then((textCache) => {
const dfn = button.querySelector(".button-tooltip-craft dfn");
if (dfn) {
dfn.parentElement?.append(textCache.get(`Description${dfn.textContent}`));
}
});
return button;
},
/**
* Create a button for an activity, including image, label and icons.
* @param {string} idPrefix - The ID of the to-be created search button
* @param {ItemActivity} activity - The activity for which to create a button
* @param {Character} C - The target character of the activity
* @param {(this: HTMLButtonElement, ev: MouseEvent | TouchEvent) => any} onClick - The click event listener to-be attached to the tooltip
* @param {null | ElementButton.Options} [options] - High level options for the to-be created button
* @param {null | Partial<Record<"button" | "tooltip" | "img" | "label", Omit<HTMLOptions<any>, "tag">>>} htmlOptions - Additional low-level {@link ElementCreate} options to-be applied to the either the button or tooltip
* @returns {HTMLButtonElement} - The created button
*/
CreateForActivity: function CreateForActivity(idPrefix, activity, C, onClick, options=null, htmlOptions=null) {
const id = `${idPrefix}-${activity.Activity.Name}`;
const elem = /** @type {HTMLButtonElement | null} */(document.getElementById(id));
if (elem) {
console.error(`Element "${id}" already exists`);
return elem;
}
htmlOptions ??= {};
htmlOptions.button ??= {};
htmlOptions.button.attributes ??= {};
htmlOptions.button.attributes.name ??= activity.Activity.Name;
htmlOptions.button.dataAttributes ??= {};
htmlOptions.button.dataAttributes.group ??= activity.Group;
htmlOptions.tooltip ??= {};
htmlOptions.tooltip.classList ??= [];
htmlOptions.tooltip.classList = [
...htmlOptions.tooltip.classList,
"button-tooltip-justify",
];
options ??= {};
options.label ??= ActivityDictionaryText(ActivityBuildChatTag(C, AssetGroupGet(C.AssetFamily, activity.Group), activity.Activity, true));
options.image ??= (activity.Item ? `./${AssetGetPreviewPath(activity.Item.Asset)}/${activity.Item.Asset.Name}.png` : `./Assets/Female3DCG/Activity/${activity.Activity.Name}.png`);
options.icons = [
...(options.icons ?? []),
activity.Blocked === "blocked" ? "Blocked" : undefined,
activity.Blocked === "limited" ? "AllowedLimited" : undefined,
activity.Item ? "Handheld" : undefined,
];
options.tooltipPosition ??= "bottom";
options.tooltip ??= options.icons.length ? [""] : undefined;
return ElementButton.Create(id, onClick, options, htmlOptions);
},
/**
* Reload the icons of the passed {@link ElementButton.CreateForAsset} button based on the items & characters current state.
* @param {HTMLButtonElement} button - The button in question
* @param {Asset | Item} asset - The asset (or item) for linked to the button
* @param {null | Character} C - The character wearing the asset/item (if any)
* @returns {boolean} - Whether the icons were updated or not
*/
ReloadAssetIcons: function ReloadAssetIcons(button, asset, C) {
const item = "Asset" in asset ? asset : { Asset: asset };
asset = item.Asset;
const icons = Array.from(button.querySelectorAll(".button-icon"));
const iconNamesOld = icons.map(el => el.getAttribute("data-name"));
/** @type {InventoryIcon[]} */
const iconNamesNew = [
DialogGetFavoriteStateDetails(C ?? Player, asset)?.Icon,
InventoryBlockedOrLimited(C ?? Player, item) ? "Blocked" : null,
InventoryIsAllowedLimited(C ?? Player, item) ? "AllowedLimited" : null,
...DialogGetLockIcon(item, "Property" in item),
...DialogGetAssetIcons(asset),
...DialogEffectIcons.GetIcons(item),
];
const iconNamesAdded = iconNamesNew.filter(i => i != null && !iconNamesOld.includes(i));
const iconNamesRemoved = iconNamesOld.filter(i => !/** @type {string[]} */(iconNamesNew).includes(i));
if (iconNamesAdded.length === 0 && iconNamesRemoved.length === 0) {
return false;
}
for (const icon of icons) {
if (!icon.hasAttribute("data-custom")) {
const tooltipComponentID = icon.getAttribute("aria-owns");
if (tooltipComponentID) { document.getElementById(tooltipComponentID)?.remove(); }
icon.remove();
}
}
const { iconGrid, tooltip } = ElementButton._ParseIcons(button.id, iconNamesAdded) ?? { tooltip: [] };
if (iconGrid && !button.contains(iconGrid)) {
button.append(iconGrid);
}
if (tooltip[1] && !button.contains(tooltip[1])) {
button.querySelector(".button-tooltip")?.append(...tooltip);
}
return true;
},
};
/**
* Namespace for constructing menu bars
* @namespace
*/
var ElementMenu = {
/**
* KeyDown event listener that implements menubar-style keyboard navigation
* @this {HTMLElement}
* @param {KeyboardEvent} ev
*/
_KeyDown: async function _KeyDown(ev) {
if (ev.altKey || ev.metaKey || ev.ctrlKey) {
return;
}
const parent = this.closest("[role='menu'], [role='menubar']");
if (!parent) {
return;
}
// Find the outer-most menu in case we're dealing with nested menus
let grandParent = parent;
/** @type {null | HTMLElement} */
let grandParentCandidate = grandParent.closest("[role='menubar'], [role='menu']");
while (grandParentCandidate && grandParentCandidate !== grandParent) {
grandParent = grandParentCandidate;
grandParentCandidate = grandParent.closest("[role='menubar'], [role='menu']");
}
let key = ev.key;
if (parent.getAttribute("data-direction") === "rtl") {
// Flip all the keys of the direction of the menu grid is right-to-left
switch (key) {
case "ArrowRight":
key = "ArrowLeft";
break;
case "ArrowLeft":
key = "ArrowRight";
break;
case "Home":
key = "End";
break;
case "End":
key = "Home";
break;
}
}
if (parent.getAttribute("aria-orientation") === "vertical") {
switch (key) {
case "ArrowRight":
key = "ArrowDown";
break;
case "ArrowLeft":
key = "ArrowUp";
break;
case "ArrowDown":
key = "ArrowRight";
break;
case "ArrowUp":
key = "ArrowLeft";
break;
}
}
let isTab = false;
if (key === "Tab") {
key = ev.shiftKey ? "ArrowLeft" : "ArrowRight";
isTab = true;
} else if (ev.shiftKey) {
return;
}
// Selector for all non-hidden menu items
const selector = "[role='menuitem'], [role='menuitemradio'], [role='menuitemcheckbox']";
switch (key) {
case "ArrowRight":
case "ArrowLeft": {
const elements = /** @type {HTMLElement[]} */(Array.from(grandParent.querySelectorAll(selector)).filter(e => ElementCheckVisibility(e)));
const idx = elements.indexOf(this);
if (idx === -1) {
return;
}
const increment = key === "ArrowRight" ? 1 : -1;
const elem = elements[idx + increment];
if (!elem && isTab) {
// We've reached the end/start of the menu:
// abort and let the tab-based keydown event propogate towards whatever next focusable element lays outside of the grid
return;
}
elem?.focus();
ev.preventDefault();
ev.stopPropagation();
break;
}
case "Home":
case "End": {
const elements = /** @type {HTMLElement[]} */(Array.from(grandParent.querySelectorAll(selector)).filter(e => ElementCheckVisibility(e)));
const idx = key === "Home" ? 0 : elements.length - 1;
elements[idx]?.focus();
ev.stopPropagation();
break;
}
case "ArrowUp":
case "ArrowDown": {
if (this.getAttribute("aria-haspopup") !== "true" && this.getAttribute("aria-haspopup") !== "menu") {
return;
}
// We're assuming (well, mandating really...) that click actions a sub menu
this.click();
const elements = /** @type {HTMLElement[]} */(Array.from(this.querySelectorAll(selector)).filter(e => ElementCheckVisibility(e)));
const idx = key === "ArrowUp" ? elements.length - 1 : 0;
elements[idx]?.focus();
ev.stopPropagation();
break;
}
}
},
/**
* Construct a menubar of button elements
* @example
* <div id={id} role="menubar">
* <button role="menuitem" />
* <input role="menuitem" type="text" />
* <button role="menuitem" aria-haspopup="menu">
* <div style={ display: "none" }>
* <button role="menuitem" />
* <button role="menuitem" />
* ...
* </div>
* </button>
* ...
* </div>
* @param {string} id - The menu's ID
* @param {readonly (string | Node | HTMLOptions<keyof HTMLElementTagNameMap>)[]} menuItems - The menu's content.
* Any `<button>` element without a role (regardless of nesting) will be assigned the `menuitem` role and thus be elligble for menu-style navigation.
* Buttons that open a sub-menu _must_ have the `aria-haspopup: "menu"` attribute set and must be able to do so via a click action.
* @param {Object} [options]
* @param {"ltr" | "rtl"} [options.direction] - The direction of the menu. Should match the value of the CSS `direction` property if provided
* @param {null | Partial<Record<"menu", Omit<HTMLOptions<any>, "tag">>>} htmlOptions - Additional {@link ElementCreate} options to-be applied to the respective (child) element
* @returns {HTMLDivElement} - The menu
*/
Create: function Create(id, menuItems, options=null, htmlOptions=null) {
let elem = /** @type {HTMLDivElement | null} */(document.getElementById(id));
if (elem) {
console.error(`Element "${id}" already exists`);
return elem;
}
options ??= {};
const direction = options.direction ?? "ltr";
htmlOptions ??= {};
const menuOptions = htmlOptions.menu ?? {};
elem = ElementCreate({
...menuOptions,
tag: "div",
attributes: {
id,
role: "menubar",
"screen-generated": CurrentScreen,
...(menuOptions.attributes ?? {}),
},
parent: menuOptions.parent ?? document.body,
dataAttributes: { direction, ...(menuOptions.dataAttributes ?? {}) },
classList: ["menubar", "HideOnPopup", ...(menuOptions.classList ?? [])],
children: [...menuItems, ...(menuOptions.children ?? [])],
});
let first = true;
elem.querySelectorAll("button, [role='menuitem'], [role='menuitemradio'], [role='menuitemcheckbox']").forEach((menuitem) => {
let role = menuitem.getAttribute("role");
if (!role) {
role = "menuitem";
menuitem.setAttribute("role", "menuitem");
}
if (role !== "menuitem" && role !== "menuitemradio" && role !== "menuitemcheckbox") {
return;
}
// Can't directly use `Element.checkVisibility` here as the menubar is not guaranteed to have a parent at this point
if (first && (menuitem instanceof HTMLElement && menuitem.style.display !== "none") && getComputedStyle(menuitem).display !== "none") {
first = false;
menuitem.setAttribute("tabindex", "0");
} else {
menuitem.setAttribute("tabindex", "-1");
}
menuitem.addEventListener("keydown", this._KeyDown);
// Ensure that disabled buttons use `aria-disabled` in order to keep them focusable
if (menuitem instanceof HTMLButtonElement && menuitem.disabled) {
menuitem.disabled = false;
menuitem.setAttribute("aria-disabled", "true");
}
});
return elem;
},
/**
* Append a menuitem to the passed menubar
* @param {HTMLElement} menu - The menubar
* @param {readonly HTMLElement[]} menuitems - The to-be prepended menuitem
*/
AppendButton: function AppendButton(menu, ...menuitems) {
if (!menu || !menuitems) {
return;
}
let i = 0;
const firstMenuItem = menu.querySelector("[role='menuitem'][tab-index='0'], [role='menuitemradio'][tab-index='0'], [role='menuitemcheckbox'][tab-index='0']");
for (const elem of menuitems) {
let role = elem.getAttribute("role");
if (!role) {
role = "menuitem";
elem.setAttribute("role", "menuitem");
}
if (role !== "menuitem" && role !== "menuitemradio" && role !== "menuitemcheckbox") {
continue;
}
if (i === 0 && !firstMenuItem) {
elem.tabIndex = 0;
} else {
elem.tabIndex = -1;
}
elem.addEventListener("keydown", this._KeyDown);
i++;
}
menu.append(...menuitems);
},
/**
* Prepend a menuitem to the passed menubar
* @param {HTMLElement} menu - The menubar
* @param {readonly HTMLElement[]} menuitems - The to-be prepended menuitem
*/
PrependItem: function PrependButton(menu, ...menuitems) {
if (!menu || !menuitems) {
return;
}
let i = 0;
const firstMenuItem = menu.querySelector("[role='menuitem'][tab-index='0'], [role='menuitemradio'][tab-index='0'], [role='menuitemcheckbox'][tab-index='0']");
for (const elem of menuitems) {
let role = elem.getAttribute("role");
if (!role) {
role = "menuitem";
elem.setAttribute("role", "menuitem");
}
if (role !== "menuitem" && role !== "menuitemradio" && role !== "menuitemcheckbox") {
continue;
}
if (i === 0) {
firstMenuItem?.setAttribute("tabindex", "-1");
elem.tabIndex = 0;
} else {
elem.tabIndex = -1;
}
elem.addEventListener("keydown", this._KeyDown);
i++;
}
menu.prepend(...menuitems);
},
};
/**
* Namespace for creating DOM checkboxes.
*/
var ElementCheckbox = {
/**
* A unique element ID-suffix to-be assigned to checkboxes without an explicit ID.
* @private
*/
_idCounter: 0,
/**
* Construct an return a DOM checkbox element (`<input type="checkbox">`)
* @param {null | string} id - The ID of the element, or `null` if one must be assigned automatically
* @param {(this: HTMLInputElement, ev: Event) => any} onChange - The change event listener to-be fired upon checkbox clicks
* @param {null | ElementCheckbox.Options} options - High level options for the to-be created checkbox
* @param {null | Partial<Record<"checkbox", Omit<HTMLOptions<any>, "tag">>>} htmlOptions - Additional {@link ElementCreate} options to-be applied to the respective (child) element
*/
Create: function Create(id, onChange, options=null, htmlOptions=null) {
id ??= `checkbox-${ElementCheckbox._idCounter++}`;
const checkbox = document.getElementById(id);
if (checkbox) {
console.error(`Element "${id}" already exists`);
return checkbox;
}
options ??= {};
const checkboxOptions = htmlOptions?.checkbox ?? {};
return ElementCreate({
...checkboxOptions,
tag: "input",
attributes: {
id,
type: "checkbox",
disabled: options.disabled,
checked: options.checked,
value: options.value,
...(checkboxOptions.attributes ?? {}),
},
classList: ["checkbox", ...(checkboxOptions.classList ?? [])],
eventListeners: {
change: onChange,
...(checkboxOptions.eventListeners ?? {}),
},
});
},
};
/**
* Return whether an element is visible or not.
*
* Approximate polyfill of [`Element.checkVisibility()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/checkVisibility),
* as its browser support is still somewhat limited (~88% at the time of writing).
* @param {Element} el - The element in question
* @param {CheckVisibilityOptions} [options] - Additional options to-be passed to `Element.checkVisibility()`
* @returns {boolean} - Whether the passed element is visible or not
*/
function ElementCheckVisibility(el, options) {
if (!el) {
return false;
}
if (typeof el.checkVisibility === "function") {
options ??= {};
return el.checkVisibility({ ...options, checkVisibilityCSS: options.checkVisibilityCSS ?? true });
} else {
// @ts-expect-error: Element does not expose style but HTMLElement does
return (!el.style || el.style.display !== "none") && getComputedStyle(el).display !== "none";
}
}