This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
/**
* Custom HTMLElement implementations for use in template rendering.
* @module elements
*/
import HTMLDocumentTagsElement from "./document-tags.mjs";
import HTMLFilePickerElement from "./file-picker.mjs";
import HTMLHueSelectorSlider from "./hue-slider.mjs";
import {HTMLMultiSelectElement, HTMLMultiCheckboxElement} from "./multi-select.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
import HTMLColorPickerElement from "./color-picker.mjs";
import HTMLRangePickerElement from "./range-picker.mjs";
import HTMLProseMirrorElement from "./prosemirror-editor.mjs";
export {default as AbstractFormInputElement} from "./form-element.mjs";
export {default as HTMLColorPickerElement} from "./color-picker.mjs";
export {default as HTMLDocumentTagsElement} from "./document-tags.mjs";
export {default as HTMLFilePickerElement} from "./file-picker.mjs";
export {default as HTMLHueSelectorSlider} from "./hue-slider.mjs"
export {default as HTMLRangePickerElement} from "./range-picker.mjs"
export {default as HTMLStringTagsElement} from "./string-tags.mjs"
export {default as HTMLProseMirrorElement} from "./prosemirror-editor.mjs";
export * from "./multi-select.mjs";
// Define custom elements
window.customElements.define(HTMLColorPickerElement.tagName, HTMLColorPickerElement);
window.customElements.define(HTMLDocumentTagsElement.tagName, HTMLDocumentTagsElement);
window.customElements.define(HTMLFilePickerElement.tagName, HTMLFilePickerElement);
window.customElements.define(HTMLHueSelectorSlider.tagName, HTMLHueSelectorSlider);
window.customElements.define(HTMLMultiSelectElement.tagName, HTMLMultiSelectElement);
window.customElements.define(HTMLMultiCheckboxElement.tagName, HTMLMultiCheckboxElement);
window.customElements.define(HTMLRangePickerElement.tagName, HTMLRangePickerElement);
window.customElements.define(HTMLStringTagsElement.tagName, HTMLStringTagsElement);
window.customElements.define(HTMLProseMirrorElement.tagName, HTMLProseMirrorElement);

View File

@@ -0,0 +1,103 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* A custom HTMLElement used to select a color using a linked pair of input fields.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLColorPickerElement extends AbstractFormInputElement {
constructor() {
super();
this._setValue(this.getAttribute("value")); // Initialize existing color value
}
/** @override */
static tagName = "color-picker";
/* -------------------------------------------- */
/**
* The button element to add a new document.
* @type {HTMLInputElement}
*/
#colorSelector;
/**
* The input element to define a Document UUID.
* @type {HTMLInputElement}
*/
#colorString;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create string input element
this.#colorString = this._primaryInput = document.createElement("input");
this.#colorString.type = "text";
this.#colorString.placeholder = this.getAttribute("placeholder") || "";
this._applyInputAttributes(this.#colorString);
// Create color selector element
this.#colorSelector = document.createElement("input");
this.#colorSelector.type = "color";
this._applyInputAttributes(this.#colorSelector);
return [this.#colorString, this.#colorSelector];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#colorString ) return; // Not yet connected
this.#colorString.value = this._value;
this.#colorSelector.value = this._value || this.#colorString.placeholder || "#000000";
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
const onChange = this.#onChangeInput.bind(this);
this.#colorString.addEventListener("change", onChange);
this.#colorSelector.addEventListener("change", onChange);
}
/* -------------------------------------------- */
/**
* Handle changes to one of the inputs of the color picker element.
* @param {InputEvent} event The originating input change event
*/
#onChangeInput(event) {
event.stopPropagation();
this.value = event.currentTarget.value;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#colorString.toggleAttribute("disabled", disabled);
this.#colorSelector.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLColorPickerElement using provided configuration data.
* @param {FormInputConfig} config
* @returns {HTMLColorPickerElement}
*/
static create(config) {
const picker = document.createElement(HTMLColorPickerElement.tagName);
picker.name = config.name;
picker.setAttribute("value", config.value ?? "");
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,344 @@
import AbstractFormInputElement from "./form-element.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} DocumentTagsInputConfig
* @property {string} [type] A specific document type in CONST.ALL_DOCUMENT_TYPES
* @property {boolean} [single] Only allow referencing a single document. In this case the submitted form value will
* be a single UUID string rather than an array
* @property {number} [max] Only allow attaching a maximum number of documents
*/
/**
* A custom HTMLElement used to render a set of associated Documents referenced by UUID.
* @extends {AbstractFormInputElement<string|string[]|null>}
*/
export default class HTMLDocumentTagsElement extends AbstractFormInputElement {
constructor() {
super();
this._initializeTags();
}
/** @override */
static tagName = "document-tags";
/* -------------------------------------------- */
/**
* @override
* @type {Record<string, string>}
* @protected
*/
_value = {};
/**
* The button element to add a new document.
* @type {HTMLButtonElement}
*/
#button;
/**
* The input element to define a Document UUID.
* @type {HTMLInputElement}
*/
#input;
/**
* The list of tagged documents.
* @type {HTMLDivElement}
*/
#tags;
/* -------------------------------------------- */
/**
* Restrict this element to documents of a particular type.
* @type {string|null}
*/
get type() {
return this.getAttribute("type");
}
set type(value) {
if ( !value ) return this.removeAttribute("type");
if ( !CONST.ALL_DOCUMENT_TYPES.includes(value) ) {
throw new Error(`"${value}" is not a valid Document type in CONST.ALL_DOCUMENT_TYPES`);
}
this.setAttribute("type", value);
}
/* -------------------------------------------- */
/**
* Restrict to only allow referencing a single Document instead of an array of documents.
* @type {boolean}
*/
get single() {
return this.hasAttribute("single");
}
set single(value) {
this.toggleAttribute("single", value === true);
}
/* -------------------------------------------- */
/**
* Allow a maximum number of documents to be tagged to the element.
* @type {number}
*/
get max() {
const max = parseInt(this.getAttribute("max"));
return isNaN(max) ? Infinity : max;
}
set max(value) {
if ( Number.isInteger(value) && (value > 0) ) this.setAttribute("max", String(value));
else this.removeAttribute("max");
}
/* -------------------------------------------- */
/**
* Initialize innerText or an initial value attribute of the element as a serialized JSON array.
* @protected
*/
_initializeTags() {
const initial = this.getAttribute("value") || this.innerText || "";
const tags = initial ? initial.split(",") : [];
for ( const t of tags ) {
try {
this.#add(t);
} catch(err) {
this._value[t] = `${t} [INVALID]`; // Display invalid UUIDs as a raw string
}
}
this.innerText = "";
this.removeAttribute("value");
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create tags list
this.#tags = document.createElement("div");
this.#tags.className = "tags input-element-tags";
// Create input element
this.#input = this._primaryInput = document.createElement("input");
this.#input.type = "text";
this.#input.placeholder = game.i18n.format("HTMLDocumentTagsElement.PLACEHOLDER", {
type: game.i18n.localize(this.type ? getDocumentClass(this.type).metadata.label : "DOCUMENT.Document")});
// Create button
this.#button = document.createElement("button");
this.#button.type = "button"
this.#button.className = "icon fa-solid fa-file-plus";
this.#button.dataset.tooltip = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Add");
this.#button.setAttribute("aria-label", this.#button.dataset.tooltip);
return [this.#tags, this.#input, this.#button];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#tags ) return; // Not yet connected
const tags = Object.entries(this._value).map(([k, v]) => this.constructor.renderTag(k, v, this.editable));
this.#tags.replaceChildren(...tags);
}
/* -------------------------------------------- */
/**
* Create an HTML string fragment for a single document tag.
* @param {string} uuid The document UUID
* @param {string} name The document name
* @param {boolean} [editable=true] Is the tag editable?
* @returns {HTMLDivElement}
*/
static renderTag(uuid, name, editable=true) {
const div = HTMLStringTagsElement.renderTag(uuid, TextEditor.truncateText(name, {maxLength: 32}), editable);
div.classList.add("document-tag");
div.querySelector("span").dataset.tooltip = uuid;
if ( editable ) {
const t = game.i18n.localize("ELEMENTS.DOCUMENT_TAGS.Remove");
const a = div.querySelector("a");
a.dataset.tooltip = t;
a.ariaLabel = t;
}
return div;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#button.addEventListener("click", () => this.#tryAdd(this.#input.value));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
this.addEventListener("drop", this.#onDrop.bind(this));
}
/* -------------------------------------------- */
/**
* Remove a single coefficient by clicking on its tag.
* @param {PointerEvent} event
*/
#onClickTag(event) {
if ( !event.target.classList.contains("remove") ) return;
const tag = event.target.closest(".tag");
delete this._value[tag.dataset.key];
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
/* -------------------------------------------- */
/**
* Add a new document tag by pressing the ENTER key in the UUID input field.
* @param {KeyboardEvent} event
*/
#onKeydown(event) {
if ( event.key !== "Enter" ) return;
event.preventDefault();
event.stopPropagation();
this.#tryAdd(this.#input.value);
}
/* -------------------------------------------- */
/**
* Handle data dropped onto the form element.
* @param {DragEvent} event
*/
#onDrop(event) {
event.preventDefault();
const dropData = TextEditor.getDragEventData(event);
if ( dropData.uuid ) this.#tryAdd(dropData.uuid);
}
/* -------------------------------------------- */
/**
* Add a Document to the tagged set using the value of the input field.
* @param {string} uuid The UUID to attempt to add
*/
#tryAdd(uuid) {
try {
this.#add(uuid);
this._refresh();
} catch(err) {
ui.notifications.error(err.message);
}
this.#input.value = "";
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this.#input.focus();
}
/* -------------------------------------------- */
/**
* Validate that the tagged document is allowed to be added to this field.
* Subclasses may impose more strict validation as to which types of documents are allowed.
* @param {foundry.abstract.Document|object} document A candidate document or compendium index entry to tag
* @throws {Error} An error if the candidate document is not allowed
*/
_validateDocument(document) {
const {type, max} = this;
if ( type && (document.documentName !== type) ) throw new Error(`Incorrect document type "${document.documentName}"`
+ ` provided to document tag field which requires "${type}" documents.`);
const n = Object.keys(this._value).length;
if ( n >= max ) throw new Error(`You may only attach at most ${max} Documents to the "${this.name}" field`);
}
/* -------------------------------------------- */
/**
* Add a new UUID to the tagged set, throwing an error if the UUID is not valid.
* @param {string} uuid The UUID to add
* @throws {Error} If the UUID is not valid
*/
#add(uuid) {
// Require the UUID to exist
let record;
const {id} = foundry.utils.parseUuid(uuid);
if ( id ) record = fromUuidSync(uuid);
else if ( this.type ) {
const collection = game.collections.get(this.type);
record = collection.get(uuid);
}
if ( !record ) throw new Error(`Invalid document UUID "${uuid}" provided to document tag field.`);
// Require a certain type of document
this._validateDocument(record);
// Replace singleton
if ( this.single ) {
for ( const k of Object.keys(this._value) ) delete this._value[k];
}
// Record the document
this._value[uuid] = record.name;
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
const uuids = Object.keys(this._value);
if ( this.single ) return uuids[0] ?? null;
else return uuids;
}
/** @override */
_setValue(value) {
this._value = {};
if ( !value ) return;
if ( typeof value === "string" ) value = [value];
for ( const uuid of value ) this.#add(uuid);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input?.toggleAttribute("disabled", disabled);
this.#button?.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLDocumentTagsElement using provided configuration data.
* @param {FormInputConfig & DocumentTagsInputConfig} config
* @returns {HTMLDocumentTagsElement}
*/
static create(config) {
const tags = /** @type {HTMLDocumentTagsElement} */ document.createElement(HTMLDocumentTagsElement.tagName);
tags.name = config.name;
// Coerce value to an array
let values;
if ( config.value instanceof Set ) values = Array.from(config.value);
else if ( !Array.isArray(config.value) ) values = [config.value];
else values = config.value;
tags.setAttribute("value", values);
tags.type = config.type;
tags.max = config.max;
tags.single = config.single;
foundry.applications.fields.setInputAttributes(tags, config);
return tags;
}
}

View File

@@ -0,0 +1,158 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} FilePickerInputConfig
* @property {FilePickerOptions.type} [type]
* @property {string} [placeholder]
* @property {boolean} [noupload]
*/
/**
* A custom HTML element responsible for rendering a file input field and associated FilePicker button.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLFilePickerElement extends AbstractFormInputElement {
/** @override */
static tagName = "file-picker";
/**
* The file path selected.
* @type {HTMLInputElement}
*/
input;
/**
* A button to open the file picker interface.
* @type {HTMLButtonElement}
*/
button;
/**
* A reference to the FilePicker application instance originated by this element.
* @type {FilePicker}
*/
picker;
/* -------------------------------------------- */
/**
* A type of file which can be selected in this field.
* @see {@link FilePicker.FILE_TYPES}
* @type {FilePickerOptions.type}
*/
get type() {
return this.getAttribute("type") ?? "any";
}
set type(value) {
if ( !FilePicker.FILE_TYPES.includes(value) ) throw new Error(`Invalid type "${value}" provided which must be a `
+ "value in FilePicker.TYPES");
this.setAttribute("type", value);
}
/* -------------------------------------------- */
/**
* Prevent uploading new files as part of this element's FilePicker dialog.
* @type {boolean}
*/
get noupload() {
return this.hasAttribute("noupload");
}
set noupload(value) {
this.toggleAttribute("noupload", value === true);
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Initialize existing value
this._value ??= this.getAttribute("value") || this.innerText || "";
this.removeAttribute("value");
// Create an input field
const elements = [];
this.input = this._primaryInput = document.createElement("input");
this.input.className = "image";
this.input.type = "text";
this.input.placeholder = this.getAttribute("placeholder") ?? "path/to/file.ext";
elements.push(this.input);
// Disallow browsing for some users
if ( game.world && !game.user.can("FILES_BROWSE") ) return elements;
// Create a FilePicker button
this.button = document.createElement("button");
this.button.className = "fa-solid fa-file-import fa-fw";
this.button.type = "button";
this.button.dataset.tooltip = game.i18n.localize("FILES.BrowseTooltip");
this.button.setAttribute("aria-label", this.button.dataset.tooltip);
this.button.tabIndex = -1;
elements.push(this.button);
return elements;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
this.input.value = this._value;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.input.disabled = disabled;
if ( this.button ) this.button.disabled = disabled;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.input.addEventListener("input", () => this._value = this.input.value);
this.button?.addEventListener("click", this.#onClickButton.bind(this));
}
/* -------------------------------------------- */
/**
* Handle clicks on the button element to render the FilePicker UI.
* @param {PointerEvent} event The initiating click event
*/
#onClickButton(event) {
event.preventDefault();
this.picker = new FilePicker({
type: this.type,
current: this.value,
allowUpload: !this.noupload,
callback: src => this.value = src
});
return this.picker.browse();
}
/* -------------------------------------------- */
/**
* Create a HTMLFilePickerElement using provided configuration data.
* @param {FormInputConfig<string> & FilePickerInputConfig} config
*/
static create(config) {
const picker = document.createElement(this.tagName);
picker.name = config.name;
picker.setAttribute("value", config.value || "");
picker.type = config.type;
picker.noupload = config.noupload;
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,214 @@
/**
* An abstract custom HTMLElement designed for use with form inputs.
* @abstract
* @template {any} FormInputValueType
*
* @fires {Event} input An "input" event when the value of the input changes
* @fires {Event} change A "change" event when the value of the element changes
*/
export default class AbstractFormInputElement extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals();
}
/**
* The HTML tag name used by this element.
* @type {string}
*/
static tagName;
/**
* Declare that this custom element provides form element functionality.
* @type {boolean}
*/
static formAssociated = true;
/**
* Attached ElementInternals which provides form handling functionality.
* @type {ElementInternals}
* @protected
*/
_internals;
/**
* The primary input (if any). Used to determine what element should receive focus when an associated label is clicked
* on.
* @type {HTMLElement}
* @protected
*/
_primaryInput;
/**
* The form this element belongs to.
* @type {HTMLFormElement}
*/
get form() {
return this._internals.form;
}
/* -------------------------------------------- */
/* Element Properties */
/* -------------------------------------------- */
/**
* The input element name.
* @type {string}
*/
get name() {
return this.getAttribute("name");
}
set name(value) {
this.setAttribute("name", value);
}
/* -------------------------------------------- */
/**
* The value of the input element.
* @type {FormInputValueType}
*/
get value() {
return this._getValue();
}
set value(value) {
this._setValue(value);
this.dispatchEvent(new Event("input", {bubbles: true, cancelable: true}));
this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
this._refresh();
}
/**
* The underlying value of the element.
* @type {FormInputValueType}
* @protected
*/
_value;
/* -------------------------------------------- */
/**
* Return the value of the input element which should be submitted to the form.
* @returns {FormInputValueType}
* @protected
*/
_getValue() {
return this._value;
}
/* -------------------------------------------- */
/**
* Translate user-provided input value into the format that should be stored.
* @param {FormInputValueType} value A new value to assign to the element
* @throws {Error} An error if the provided value is invalid
* @protected
*/
_setValue(value) {
this._value = value;
}
/* -------------------------------------------- */
/**
* Is this element disabled?
* @type {boolean}
*/
get disabled() {
return this.hasAttribute("disabled");
}
set disabled(value) {
this.toggleAttribute("disabled", value);
this._toggleDisabled(!this.editable);
}
/* -------------------------------------------- */
/**
* Is this field editable? The field can be neither disabled nor readonly.
* @type {boolean}
*/
get editable() {
return !(this.hasAttribute("disabled") || this.hasAttribute("readonly"));
}
/* -------------------------------------------- */
/**
* Special behaviors that the subclass should implement when toggling the disabled state of the input.
* @param {boolean} disabled The new disabled state
* @protected
*/
_toggleDisabled(disabled) {}
/* -------------------------------------------- */
/* Element Lifecycle */
/* -------------------------------------------- */
/**
* Initialize the custom element, constructing its HTML.
*/
connectedCallback() {
const elements = this._buildElements();
this.replaceChildren(...elements);
this._refresh();
this._toggleDisabled(!this.editable);
this.addEventListener("click", this._onClick.bind(this));
this._activateListeners();
}
/* -------------------------------------------- */
/**
* Create the HTML elements that should be included in this custom element.
* Elements are returned as an array of ordered children.
* @returns {HTMLElement[]}
* @protected
*/
_buildElements() {
return [];
}
/* -------------------------------------------- */
/**
* Refresh the active state of the custom element.
* @protected
*/
_refresh() {}
/* -------------------------------------------- */
/**
* Apply key attributes on the containing custom HTML element to input elements contained within it.
* @internal
*/
_applyInputAttributes(input) {
input.toggleAttribute("required", this.hasAttribute("required"));
input.toggleAttribute("disabled", this.hasAttribute("disabled"));
input.toggleAttribute("readonly", this.hasAttribute("readonly"));
}
/* -------------------------------------------- */
/**
* Activate event listeners which add dynamic behavior to the custom element.
* @protected
*/
_activateListeners() {}
/* -------------------------------------------- */
/**
* Special handling when the custom element is clicked. This should be implemented to transfer focus to an
* appropriate internal element.
* @param {PointerEvent} event
* @protected
*/
_onClick(event) {
if ( event.target === this ) this._primaryInput?.focus?.();
}
}

View File

@@ -0,0 +1,88 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* A class designed to standardize the behavior for a hue selector UI component.
* @extends {AbstractFormInputElement<number>}
*/
export default class HTMLHueSelectorSlider extends AbstractFormInputElement {
/** @override */
static tagName = "hue-slider";
/**
* The color range associated with this element.
* @type {HTMLInputElement|null}
*/
#input;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Initialize existing value
this._setValue(this.getAttribute("value"));
// Build elements
this.#input = this._primaryInput = document.createElement("input");
this.#input.className = "color-range";
this.#input.type = "range";
this.#input.min = "0";
this.#input.max = "360";
this.#input.step = "1";
this.#input.disabled = this.disabled;
this.#input.value = this._value * 360;
return [this.#input];
}
/* -------------------------------------------- */
/**
* Refresh the active state of the custom element.
* @protected
*/
_refresh() {
this.#input.style.setProperty("--color-thumb", Color.fromHSL([this._value, 1, 0.5]).css);
}
/* -------------------------------------------- */
/**
* Activate event listeners which add dynamic behavior to the custom element.
* @protected
*/
_activateListeners() {
this.#input.oninput = this.#onInputColorRange.bind(this);
}
/* -------------------------------------------- */
/**
* Update the thumb and the value.
* @param {FormDataEvent} event
*/
#onInputColorRange(event) {
event.preventDefault();
event.stopImmediatePropagation();
this.value = this.#input.value / 360;
}
/* -------------------------------------------- */
/* Form Handling
/* -------------------------------------------- */
/** @override */
_setValue(value) {
value = Number(value);
if ( !value.between(0, 1) ) throw new Error("The value of a hue-slider must be on the range [0,1]");
this._value = value;
this.setAttribute("value", String(value));
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input.disabled = disabled;
}
}

View File

@@ -0,0 +1,361 @@
import AbstractFormInputElement from "./form-element.mjs";
import HTMLStringTagsElement from "./string-tags.mjs";
/**
* An abstract base class designed to standardize the behavior for a multi-select UI component.
* Multi-select components return an array of values as part of form submission.
* Different implementations may provide different experiences around how inputs are presented to the user.
* @extends {AbstractFormInputElement<Set<string>>}
*/
export class AbstractMultiSelectElement extends AbstractFormInputElement {
constructor() {
super();
this._value = new Set();
this._initialize();
}
/**
* Predefined <option> and <optgroup> elements which were defined in the original HTML.
* @type {(HTMLOptionElement|HTMLOptGroupElement)[]}
* @protected
*/
_options;
/**
* An object which maps option values to displayed labels.
* @type {Record<string, string>}
* @protected
*/
_choices = {};
/* -------------------------------------------- */
/**
* Preserve existing <option> and <optgroup> elements which are defined in the original HTML.
* @protected
*/
_initialize() {
this._options = [...this.children];
for ( const option of this.querySelectorAll("option") ) {
if ( !option.value ) continue; // Skip predefined options which are already blank
this._choices[option.value] = option.innerText;
if ( option.selected ) {
this._value.add(option.value);
option.selected = false;
}
}
}
/* -------------------------------------------- */
/**
* Mark a choice as selected.
* @param {string} value The value to add to the chosen set
*/
select(value) {
const exists = this._value.has(value);
if ( !exists ) {
if ( !(value in this._choices) ) {
throw new Error(`"${value}" is not an option allowed by this multi-select element`);
}
this._value.add(value);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
}
/* -------------------------------------------- */
/**
* Mark a choice as un-selected.
* @param {string} value The value to delete from the chosen set
*/
unselect(value) {
const exists = this._value.has(value);
if ( exists ) {
this._value.delete(value);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
return Array.from(this._value);
}
/** @override */
_setValue(value) {
if ( !Array.isArray(value) ) {
throw new Error("The value assigned to a multi-select element must be an array.");
}
if ( value.some(v => !(v in this._choices)) ) {
throw new Error("The values assigned to a multi-select element must all be valid options.");
}
this._value.clear();
for ( const v of value ) this._value.add(v);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow using a select element as the input mechanism.
*
* @example Multi-Select HTML Markup
* ```html
* <multi-select name="select-many-things">
* <optgroup label="Basic Options">
* <option value="foo">Foo</option>
* <option value="bar">Bar</option>
* <option value="baz">Baz</option>
* </optgroup>
* <optgroup label="Advanced Options">
* <option value="fizz">Fizz</option>
* <option value="buzz">Buzz</option>
* </optgroup>
* </multi-select>
* ```
*/
export class HTMLMultiSelectElement extends AbstractMultiSelectElement {
/** @override */
static tagName = "multi-select";
/**
* A select element used to choose options.
* @type {HTMLSelectElement}
*/
#select;
/**
* A display element which lists the chosen options.
* @type {HTMLDivElement}
*/
#tags;
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create select element
this.#select = this._primaryInput = document.createElement("select");
this.#select.insertAdjacentHTML("afterbegin", '<option value=""></option>');
this.#select.append(...this._options);
this.#select.disabled = !this.editable;
// Create a div element for display
this.#tags = document.createElement("div");
this.#tags.className = "tags input-element-tags";
return [this.#tags, this.#select];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
// Update the displayed tags
const tags = Array.from(this._value).map(id => {
return HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable);
});
this.#tags.replaceChildren(...tags);
// Disable selected options
for ( const option of this.#select.querySelectorAll("option") ) {
option.disabled = this._value.has(option.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#select.addEventListener("change", this.#onChangeSelect.bind(this));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
}
/* -------------------------------------------- */
/**
* Handle changes to the Select input, marking the selected option as a chosen value.
* @param {Event} event The change event on the select element
*/
#onChangeSelect(event) {
event.preventDefault();
event.stopImmediatePropagation();
const select = event.currentTarget;
if ( !select.value ) return; // Ignore selection of the blank value
this.select(select.value);
select.value = "";
}
/* -------------------------------------------- */
/**
* Handle click events on a tagged value, removing it from the chosen set.
* @param {PointerEvent} event The originating click event on a chosen tag
*/
#onClickTag(event) {
event.preventDefault();
if ( !event.target.classList.contains("remove") ) return;
if ( !this.editable ) return;
const tag = event.target.closest(".tag");
this.unselect(tag.dataset.key);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#select.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLMultiSelectElement using provided configuration data.
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
* @returns {HTMLMultiSelectElement}
*/
static create(config) {
return foundry.applications.fields.createMultiSelectInput(config);
}
}
/* -------------------------------------------- */
/**
* Provide a multi-select workflow as a grid of input checkbox elements.
*
* @example Multi-Checkbox HTML Markup
* ```html
* <multi-checkbox name="check-many-boxes">
* <optgroup label="Basic Options">
* <option value="foo">Foo</option>
* <option value="bar">Bar</option>
* <option value="baz">Baz</option>
* </optgroup>
* <optgroup label="Advanced Options">
* <option value="fizz">Fizz</option>
* <option value="buzz">Buzz</option>
* </optgroup>
* </multi-checkbox>
* ```
*/
export class HTMLMultiCheckboxElement extends AbstractMultiSelectElement {
/** @override */
static tagName = "multi-checkbox";
/**
* The checkbox elements used to select inputs
* @type {HTMLInputElement[]}
*/
#checkboxes;
/* -------------------------------------------- */
/** @override */
_buildElements() {
this.#checkboxes = [];
const children = [];
for ( const option of this._options ) {
if ( option instanceof HTMLOptGroupElement ) children.push(this.#buildGroup(option));
else children.push(this.#buildOption(option));
}
return children;
}
/* -------------------------------------------- */
/**
* Translate an input <optgroup> element into a <fieldset> of checkboxes.
* @param {HTMLOptGroupElement} optgroup The originally configured optgroup
* @returns {HTMLFieldSetElement} The created fieldset grouping
*/
#buildGroup(optgroup) {
// Create fieldset group
const group = document.createElement("fieldset");
group.classList.add("checkbox-group");
const legend = document.createElement("legend");
legend.innerText = optgroup.label;
group.append(legend);
// Add child options
for ( const option of optgroup.children ) {
if ( option instanceof HTMLOptionElement ) {
group.append(this.#buildOption(option));
}
}
return group;
}
/* -------------------------------------------- */
/**
* Build an input <option> element into a <label class="checkbox"> element.
* @param {HTMLOptionElement} option The originally configured option
* @returns {HTMLLabelElement} The created labeled checkbox element
*/
#buildOption(option) {
const label = document.createElement("label");
label.classList.add("checkbox");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = option.value;
checkbox.checked = this._value.has(option.value);
checkbox.disabled = this.disabled;
label.append(checkbox, option.innerText);
this.#checkboxes.push(checkbox);
return label;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
for ( const checkbox of this.#checkboxes ) {
checkbox.checked = this._value.has(checkbox.value);
}
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
for ( const checkbox of this.#checkboxes ) {
checkbox.addEventListener("change", this.#onChangeCheckbox.bind(this));
}
}
/* -------------------------------------------- */
/**
* Handle changes to a checkbox input, marking the selected option as a chosen value.
* @param {Event} event The change event on the checkbox input element
*/
#onChangeCheckbox(event) {
event.preventDefault();
event.stopImmediatePropagation();
const checkbox = event.currentTarget;
if ( checkbox.checked ) this.select(checkbox.value);
else this.unselect(checkbox.value);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
for ( const checkbox of this.#checkboxes ) {
checkbox.disabled = disabled;
}
}
}

View File

@@ -0,0 +1,248 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} ProseMirrorInputConfig
* @property {boolean} toggled Is this editor toggled (true) or always active (false)
* @property {string} [enriched] If the editor is toggled, provide the enrichedHTML which is displayed while
* the editor is not active.
* @property {boolean} collaborate Does this editor instance support collaborative editing?
* @property {boolean} compact Should the editor be presented in compact mode?
* @property {string} documentUUID A Document UUID. Required for collaborative editing
*/
/**
* A custom HTML element responsible displaying a ProseMirror rich text editor.
* @extends {AbstractFormInputElement<string>}
*/
export default class HTMLProseMirrorElement extends AbstractFormInputElement {
constructor() {
super();
// Initialize raw content
this._setValue(this.getAttribute("value") || "");
this.removeAttribute("value");
// Initialize enriched content
this.#toggled = this.hasAttribute("toggled");
this.#enriched = this.innerHTML;
}
/** @override */
static tagName = "prose-mirror";
/**
* Is the editor in active edit mode?
* @type {boolean}
*/
#active = false;
/**
* The ProseMirror editor instance.
* @type {ProseMirrorEditor}
*/
#editor;
/**
* Current editor contents
* @type {HTMLDivElement}
*/
#content;
/**
* Does this editor function via a toggle button? Or is it always active?
* @type {boolean}
*/
#toggled;
/**
* Enriched content which is optionally used if the editor is toggled.
* @type {string}
*/
#enriched;
/**
* An optional edit button which activates edit mode for the editor
* @type {HTMLButtonElement|null}
*/
#button = null;
/* -------------------------------------------- */
/**
* Actions to take when the custom element is removed from the document.
*/
disconnectedCallback() {
this.#editor?.destroy();
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
this.classList.add("editor", "prosemirror", "inactive");
const elements = [];
this.#content = document.createElement("div");
this.#content.className = "editor-content";
elements.push(this.#content);
if ( this.#toggled ) {
this.#button = document.createElement("button");
this.#button.type = "button";
this.#button.className = "icon toggle";
this.#button.innerHTML = `<i class="fa-solid fa-edit"></i>`;
elements.push(this.#button);
}
return elements;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( this.#active ) return; // It is not safe to replace the content while the editor is active
if ( this.#toggled ) this.#content.innerHTML = this.#enriched ?? this._value;
else this.#content.innerHTML = this._value;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
if ( this.#toggled ) this.#button.addEventListener("click", this.#onClickButton.bind(this));
else this.#activateEditor();
}
/* -------------------------------------------- */
/** @override */
_getValue() {
if ( this.#active ) return ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
return this._value;
}
/* -------------------------------------------- */
/**
* Activate the ProseMirror editor.
* @returns {Promise<void>}
*/
async #activateEditor() {
// If the editor was toggled, replace with raw editable content
if ( this.#toggled ) this.#content.innerHTML = this._value;
// Create the TextEditor instance
const document = await fromUuid(this.dataset.documentUuid ?? this.dataset.documentUUID);
this.#editor = await TextEditor.create({
engine: "prosemirror",
plugins: this._configurePlugins(),
fieldName: this.name,
collaborate: this.hasAttribute("collaborate"),
target: this.#content,
document
}, this._getValue());
// Toggle active state
this.#active = true;
if ( this.#button ) this.#button.disabled = true;
this.classList.add("active");
this.classList.remove("inactive");
}
/* -------------------------------------------- */
/**
* Configure ProseMirror editor plugins.
* @returns {Record<string, ProseMirror.Plugin>}
* @protected
*/
_configurePlugins() {
return {
menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
compact: this.hasAttribute("compact"),
destroyOnSave: this.#toggled,
onSave: this.#save.bind(this)
}),
keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
onSave: this.#save.bind(this)
})
};
}
/* -------------------------------------------- */
/**
* Handle clicking the editor activation button.
* @param {PointerEvent} event The triggering event.
*/
#onClickButton(event) {
event.preventDefault();
this.#activateEditor();
}
/* -------------------------------------------- */
/**
* Handle saving the editor content.
* Store new parsed HTML into the _value attribute of the element.
* If the editor is toggled, also deactivate editing mode.
*/
#save() {
const value = ProseMirror.dom.serializeString(this.#editor.view.state.doc.content);
if ( value !== this._value ) {
this._setValue(value);
this.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
}
// Deactivate a toggled editor
if ( this.#toggled ) {
this.#button.disabled = this.disabled;
this.#active = false;
this.#editor.destroy();
this.classList.remove("active");
this.classList.add("inactive");
this.replaceChildren(this.#button, this.#content);
this._refresh();
this.dispatchEvent(new Event("close", {bubbles: true, cancelable: true}));
}
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
if ( this.#toggled ) this.#button.disabled = disabled;
}
/* -------------------------------------------- */
/**
* Create a HTMLProseMirrorElement using provided configuration data.
* @param {FormInputConfig & ProseMirrorInputConfig} config
* @returns {HTMLProseMirrorElement}
*/
static create(config) {
const editor = document.createElement(HTMLProseMirrorElement.tagName);
editor.name = config.name;
// Configure editor properties
editor.toggleAttribute("collaborate", config.collaborate ?? false);
editor.toggleAttribute("compact", config.compact ?? false);
editor.toggleAttribute("toggled", config.toggled ?? false);
if ( "documentUUID" in config ) Object.assign(editor.dataset, {
documentUuid: config.documentUUID,
documentUUID: config.documentUUID
});
if ( Number.isNumeric(config.height) ) editor.style.height = `${config.height}px`;
// Un-enriched content gets temporarily assigned to the value property of the element
editor.setAttribute("value", config.value);
// Enriched content gets temporarily assigned as the innerHTML of the element
if ( config.toggled && config.enriched ) editor.innerHTML = config.enriched;
return editor;
}
}

View File

@@ -0,0 +1,166 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} RangePickerInputConfig
* @property {number} min
* @property {number} max
* @property {number} [step]
*/
/**
* A custom HTML element responsible selecting a value on a range slider with a linked number input field.
* @extends {AbstractFormInputElement<number>}
*/
export default class HTMLRangePickerElement extends AbstractFormInputElement {
constructor() {
super();
this.#min = Number(this.getAttribute("min")) ?? 0;
this.#max = Number(this.getAttribute("max")) ?? 1;
this.#step = Number(this.getAttribute("step")) || undefined;
this._setValue(Number(this.getAttribute("value"))); // Initialize existing value
}
/** @override */
static tagName = "range-picker";
/**
* The range input.
* @type {HTMLInputElement}
*/
#rangeInput;
/**
* The number input.
* @type {HTMLInputElement}
*/
#numberInput;
/**
* The minimum allowed value for the range.
* @type {number}
*/
#min;
/**
* The maximum allowed value for the range.
* @type {number}
*/
#max;
/**
* A required step size for the range.
* @type {number}
*/
#step;
/* -------------------------------------------- */
/**
* The value of the input element.
* @type {number}
*/
get valueAsNumber() {
return this._getValue();
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create range input element
const r = this.#rangeInput = document.createElement("input");
r.type = "range";
r.min = String(this.#min);
r.max = String(this.#max);
r.step = String(this.#step ?? 0.1);
this._applyInputAttributes(r);
// Create the number input element
const n = this.#numberInput = this._primaryInput = document.createElement("input");
n.type = "number";
n.min = String(this.#min);
n.max = String(this.#max);
n.step = this.#step ?? "any";
this._applyInputAttributes(n);
return [this.#rangeInput, this.#numberInput];
}
/* -------------------------------------------- */
/** @override */
_setValue(value) {
value = Math.clamp(value, this.#min, this.#max);
if ( this.#step ) value = value.toNearest(this.#step);
this._value = value;
}
/* -------------------------------------------- */
/** @override */
_refresh() {
if ( !this.#rangeInput ) return; // Not yet connected
this.#rangeInput.valueAsNumber = this.#numberInput.valueAsNumber = this._value;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
const onChange = this.#onChangeInput.bind(this);
this.#rangeInput.addEventListener("input", this.#onDragSlider.bind(this));
this.#rangeInput.addEventListener("change", onChange);
this.#numberInput.addEventListener("change", onChange);
}
/* -------------------------------------------- */
/**
* Update display of the number input as the range slider is actively changed.
* @param {InputEvent} event The originating input event
*/
#onDragSlider(event) {
event.preventDefault();
this.#numberInput.valueAsNumber = this.#rangeInput.valueAsNumber;
}
/* -------------------------------------------- */
/**
* Handle changes to one of the inputs of the range picker element.
* @param {InputEvent} event The originating input change event
*/
#onChangeInput(event) {
event.stopPropagation();
this.value = event.currentTarget.valueAsNumber;
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#rangeInput.toggleAttribute("disabled", disabled);
this.#numberInput.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLRangePickerElement using provided configuration data.
* @param {FormInputConfig & RangePickerInputConfig} config
* @returns {HTMLRangePickerElement}
*/
static create(config) {
const picker = document.createElement(HTMLRangePickerElement.tagName);
picker.name = config.name;
for ( const attr of ["value", "min", "max", "step"] ) {
if ( attr in config ) picker.setAttribute(attr, config[attr]);
}
foundry.applications.fields.setInputAttributes(picker, config);
return picker;
}
}

View File

@@ -0,0 +1,275 @@
import AbstractFormInputElement from "./form-element.mjs";
/**
* @typedef {import("../forms/fields.mjs").FormInputConfig} FormInputConfig
*/
/**
* @typedef {Object} StringTagsInputConfig
* @property {boolean} slug Automatically slugify provided strings?
*/
/**
* A custom HTML element which allows for arbitrary assignment of a set of string tags.
* This element may be used directly or subclassed to impose additional validation or functionality.
* @extends {AbstractFormInputElement<Set<string>>}
*/
export default class HTMLStringTagsElement extends AbstractFormInputElement {
constructor() {
super();
this.#slug = this.hasAttribute("slug");
this._value = new Set();
this._initializeTags();
}
/** @override */
static tagName = "string-tags";
static icons = {
add: "fa-solid fa-tag",
remove: "fa-solid fa-times"
}
static labels = {
add: "ELEMENTS.TAGS.Add",
remove: "ELEMENTS.TAGS.Remove",
placeholder: ""
}
/**
* The button element to add a new tag.
* @type {HTMLButtonElement}
*/
#button;
/**
* The input element to enter a new tag.
* @type {HTMLInputElement}
*/
#input;
/**
* The tags list of assigned tags.
* @type {HTMLDivElement}
*/
#tags;
/**
* Automatically slugify all strings provided to the element?
* @type {boolean}
*/
#slug;
/* -------------------------------------------- */
/**
* Initialize innerText or an initial value attribute of the element as a comma-separated list of currently assigned
* string tags.
* @protected
*/
_initializeTags() {
const initial = this.getAttribute("value") || this.innerText || "";
const tags = initial ? initial.split(",") : [];
for ( let tag of tags ) {
tag = tag.trim();
if ( tag ) {
if ( this.#slug ) tag = tag.slugify({strict: true});
try {
this._validateTag(tag);
} catch ( err ) {
console.warn(err.message);
continue;
}
this._value.add(tag);
}
}
this.innerText = "";
this.removeAttribute("value");
}
/* -------------------------------------------- */
/**
* Subclasses may impose more strict validation on what tags are allowed.
* @param {string} tag A candidate tag
* @throws {Error} An error if the candidate tag is not allowed
* @protected
*/
_validateTag(tag) {
if ( !tag ) throw new Error(game.i18n.localize("ELEMENTS.TAGS.ErrorBlank"));
}
/* -------------------------------------------- */
/** @override */
_buildElements() {
// Create tags list
const tags = document.createElement("div");
tags.className = "tags input-element-tags";
this.#tags = tags;
// Create input element
const input = document.createElement("input");
input.type = "text";
input.placeholder = game.i18n.localize(this.constructor.labels.placeholder);
this.#input = this._primaryInput = input;
// Create button
const button = document.createElement("button");
button.type = "button"
button.className = `icon ${this.constructor.icons.add}`;
button.dataset.tooltip = this.constructor.labels.add;
button.ariaLabel = game.i18n.localize(this.constructor.labels.add);
this.#button = button;
return [this.#tags, this.#input, this.#button];
}
/* -------------------------------------------- */
/** @override */
_refresh() {
const tags = this.value.map(tag => this.constructor.renderTag(tag, tag, this.editable));
this.#tags.replaceChildren(...tags);
}
/* -------------------------------------------- */
/**
* Render the tagged string as an HTML element.
* @param {string} tag The raw tag value
* @param {string} [label] An optional tag label
* @param {boolean} [editable=true] Is the tag editable?
* @returns {HTMLDivElement} A rendered HTML element for the tag
*/
static renderTag(tag, label, editable=true) {
const div = document.createElement("div");
div.className = "tag";
div.dataset.key = tag;
const span = document.createElement("span");
span.textContent = label ?? tag;
div.append(span);
if ( editable ) {
const t = game.i18n.localize(this.labels.remove);
const a = `<a class="remove ${this.icons.remove}" data-tooltip="${t}" aria-label="${t}"></a>`;
div.insertAdjacentHTML("beforeend", a);
}
return div;
}
/* -------------------------------------------- */
/** @override */
_activateListeners() {
this.#button.addEventListener("click", this.#addTag.bind(this));
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
this.#input.addEventListener("keydown", this.#onKeydown.bind(this));
}
/* -------------------------------------------- */
/**
* Remove a tag from the set when its removal button is clicked.
* @param {PointerEvent} event
*/
#onClickTag(event) {
if ( !event.target.classList.contains("remove") ) return;
const tag = event.target.closest(".tag");
this._value.delete(tag.dataset.key);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this._refresh();
}
/* -------------------------------------------- */
/**
* Add a tag to the set when the ENTER key is pressed in the text input.
* @param {KeyboardEvent} event
*/
#onKeydown(event) {
if ( event.key !== "Enter" ) return;
event.preventDefault();
event.stopPropagation();
this.#addTag();
}
/* -------------------------------------------- */
/**
* Add a new tag to the set upon user input.
*/
#addTag() {
let tag = this.#input.value.trim();
if ( this.#slug ) tag = tag.slugify({strict: true});
// Validate the proposed code
try {
this._validateTag(tag);
} catch(err) {
ui.notifications.error(err.message);
this.#input.value = "";
return;
}
// Ensure uniqueness
if ( this._value.has(tag) ) {
const message = game.i18n.format("ELEMENTS.TAGS.ErrorNonUnique", {tag});
ui.notifications.error(message);
this.#input.value = "";
return;
}
// Add hex
this._value.add(tag);
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
this.#input.value = "";
this._refresh();
}
/* -------------------------------------------- */
/* Form Handling */
/* -------------------------------------------- */
/** @override */
_getValue() {
return Array.from(this._value);
}
/* -------------------------------------------- */
/** @override */
_setValue(value) {
this._value.clear();
const toAdd = [];
for ( let v of value ) {
if ( this.#slug ) v = v.slugify({strict: true});
this._validateTag(v);
toAdd.push(v);
}
for ( const v of toAdd ) this._value.add(v);
}
/* -------------------------------------------- */
/** @override */
_toggleDisabled(disabled) {
this.#input.toggleAttribute("disabled", disabled);
this.#button.toggleAttribute("disabled", disabled);
}
/* -------------------------------------------- */
/**
* Create a HTMLStringTagsElement using provided configuration data.
* @param {FormInputConfig & StringTagsInputConfig} config
*/
static create(config) {
const tags = document.createElement(this.tagName);
tags.name = config.name;
const value = Array.from(config.value || []).join(",");
tags.toggleAttribute("slug", !!config.slug)
tags.setAttribute("value", value);
foundry.applications.fields.setInputAttributes(tags, config);
return tags;
}
}