Initial
This commit is contained in:
434
resources/app/client-esm/applications/forms/fields.mjs
Normal file
434
resources/app/client-esm/applications/forms/fields.mjs
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* @callback CustomFormGroup
|
||||
* @param {DataField} field
|
||||
* @param {FormGroupConfig} groupConfig
|
||||
* @param {FormInputConfig} inputConfig
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback CustomFormInput
|
||||
* @param {DataField} field
|
||||
* @param {FormInputConfig} config
|
||||
* @returns {HTMLElement|HTMLCollection}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormGroupConfig
|
||||
* @property {string} label A text label to apply to the form group
|
||||
* @property {string} [units] An optional units string which is appended to the label
|
||||
* @property {HTMLElement|HTMLCollection} input An HTML element or collection of elements which provide the inputs
|
||||
* for the group
|
||||
* @property {string} [hint] Hint text displayed as part of the form group
|
||||
* @property {string} [rootId] Some parent CSS id within which field names are unique. If provided,
|
||||
* this root ID is used to automatically assign "id" attributes to input
|
||||
* elements and "for" attributes to corresponding labels
|
||||
* @property {string[]} [classes] An array of CSS classes applied to the form group element
|
||||
* @property {boolean} [stacked=false] Is the "stacked" class applied to the form group
|
||||
* @property {boolean} [localize=false] Should labels or other elements within this form group be
|
||||
* automatically localized?
|
||||
* @property {CustomFormGroup} [widget] A custom form group widget function which replaces the default
|
||||
* group HTML generation
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template FormInputValue
|
||||
* @typedef {Object} FormInputConfig
|
||||
* @property {string} name The name of the form element
|
||||
* @property {FormInputValue} [value] The current value of the form element
|
||||
* @property {boolean} [required=false] Is the field required?
|
||||
* @property {boolean} [disabled=false] Is the field disabled?
|
||||
* @property {boolean} [readonly=false] Is the field readonly?
|
||||
* @property {boolean} [autofocus=false] Is the field autofocused?
|
||||
* @property {boolean} [localize=false] Localize values of this field?
|
||||
* @property {Record<string,string>} [dataset] Additional dataset attributes to assign to the input
|
||||
* @property {string} [placeholder] A placeholder value, if supported by the element type
|
||||
* @property {CustomFormInput} [input]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a standardized form field group.
|
||||
* @param {FormGroupConfig} config
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function createFormGroup(config) {
|
||||
let {classes, hint, label, input, rootId, stacked, localize, units} = config;
|
||||
classes ||= [];
|
||||
if ( stacked ) classes.unshift("stacked");
|
||||
classes.unshift("form-group");
|
||||
|
||||
// Assign identifiers to each input
|
||||
input = input instanceof HTMLCollection ? input : [input];
|
||||
let labelFor;
|
||||
if ( rootId ) {
|
||||
for ( const [i, el] of input.entries() ) {
|
||||
const id = [rootId, el.name, input.length > 1 ? i : ""].filterJoin("-");
|
||||
labelFor ||= id;
|
||||
el.setAttribute("id", id);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the group element
|
||||
const group = document.createElement("div");
|
||||
group.className = classes.join(" ");
|
||||
|
||||
// Label element
|
||||
const lbl = document.createElement("label");
|
||||
lbl.innerText = localize ? game.i18n.localize(label) : label;
|
||||
if ( labelFor ) lbl.setAttribute("for", labelFor);
|
||||
if ( units ) lbl.insertAdjacentHTML("beforeend", ` <span class="units">(${game.i18n.localize(units)})</span>`);
|
||||
group.prepend(lbl);
|
||||
|
||||
// Form fields and inputs
|
||||
const fields = document.createElement("div");
|
||||
fields.className = "form-fields";
|
||||
fields.append(...input);
|
||||
group.append(fields);
|
||||
|
||||
// Hint element
|
||||
if ( hint ) {
|
||||
const h = document.createElement("p");
|
||||
h.className = "hint";
|
||||
h.innerText = localize ? game.i18n.localize(hint) : hint;
|
||||
group.append(h);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an `<input type="checkbox">` element for a BooleanField.
|
||||
* @param {FormInputConfig<boolean>} config
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createCheckboxInput(config) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.name = config.name;
|
||||
if ( config.value ) input.setAttribute("checked", "");
|
||||
setInputAttributes(input, config);
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} EditorInputConfig
|
||||
* @property {string} [engine="prosemirror"]
|
||||
* @property {number} [height]
|
||||
* @property {boolean} [editable=true]
|
||||
* @property {boolean} [button=false]
|
||||
* @property {boolean} [collaborate=false]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a `<div class="editor">` element for a StringField.
|
||||
* @param {FormInputConfig<string> & EditorInputConfig} config
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function createEditorInput(config) {
|
||||
const {engine="prosemirror", editable=true, button=false, collaborate=false, height} = config;
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "editor";
|
||||
if ( height !== undefined ) editor.style.height = `${height}px`;
|
||||
|
||||
// Dataset attributes
|
||||
let dataset = { engine, collaborate };
|
||||
if ( editable ) dataset.edit = config.name;
|
||||
dataset = Object.entries(dataset).map(([k, v]) => `data-${k}="${v}"`).join(" ");
|
||||
|
||||
// Editor HTML
|
||||
let editorHTML = "";
|
||||
if ( button && editable ) editorHTML += '<a class="editor-edit"><i class="fa-solid fa-edit"></i></a>';
|
||||
editorHTML += `<div class="editor-content" ${dataset}>${config.value ?? ""}</div>`;
|
||||
editor.innerHTML = editorHTML;
|
||||
return editor;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a `<multi-select>` element for a StringField.
|
||||
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
|
||||
* @returns {HTMLSelectElement}
|
||||
*/
|
||||
export function createMultiSelectInput(config) {
|
||||
const tagName = config.type === "checkboxes" ? "multi-checkbox" : "multi-select";
|
||||
const select = document.createElement(tagName);
|
||||
select.name = config.name;
|
||||
setInputAttributes(select, config);
|
||||
const groups = prepareSelectOptionGroups(config);
|
||||
for ( const g of groups ) {
|
||||
let parent = select;
|
||||
if ( g.group ) parent = _appendOptgroup(g.group, select);
|
||||
for ( const o of g.options ) _appendOption(o, parent);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} NumberInputConfig
|
||||
* @property {number} min
|
||||
* @property {number} max
|
||||
* @property {number|"any"} step
|
||||
* @property {"range"|"number"} [type]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create an `<input type="number">` element for a NumberField.
|
||||
* @param {FormInputConfig<number> & NumberInputConfig} config
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createNumberInput(config) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
if ( config.name ) input.name = config.name;
|
||||
|
||||
// Assign value
|
||||
let step = typeof config.step === "number" ? config.step : "any";
|
||||
let value = config.value;
|
||||
if ( Number.isNumeric(value) ) {
|
||||
if ( typeof config.step === "number" ) value = value.toNearest(config.step);
|
||||
input.setAttribute("value", String(value));
|
||||
}
|
||||
else input.setAttribute("value", "");
|
||||
|
||||
// Min, max, and step size
|
||||
if ( typeof config.min === "number" ) input.setAttribute("min", String(config.min));
|
||||
if ( typeof config.max === "number" ) input.setAttribute("max", String(config.max));
|
||||
input.setAttribute("step", String(step));
|
||||
setInputAttributes(input, config);
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormSelectOption
|
||||
* @property {string} [value]
|
||||
* @property {string} [label]
|
||||
* @property {string} [group]
|
||||
* @property {boolean} [disabled]
|
||||
* @property {boolean} [selected]
|
||||
* @property {boolean} [rule]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SelectInputConfig
|
||||
* @property {FormSelectOption[]} options
|
||||
* @property {string[]} [groups] An option to control the order and display of optgroup elements. The order of
|
||||
* strings defines the displayed order of optgroup elements.
|
||||
* A blank string may be used to define the position of ungrouped options.
|
||||
* If not defined, the order of groups corresponds to the order of options.
|
||||
* @property {string} [blank]
|
||||
* @property {string} [valueAttr] An alternative value key of the object passed to the options array
|
||||
* @property {string} [labelAttr] An alternative label key of the object passed to the options array
|
||||
* @property {boolean} [localize=false] Localize value labels
|
||||
* @property {boolean} [sort=false] Sort options alphabetically by label within groups
|
||||
* @property {"single"|"multi"|"checkboxes"} [type] Customize the type of select that is created
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a `<select>` element for a StringField.
|
||||
* @param {FormInputConfig<string> & SelectInputConfig} config
|
||||
* @returns {HTMLSelectElement}
|
||||
*/
|
||||
export function createSelectInput(config) {
|
||||
const select = document.createElement("select");
|
||||
select.name = config.name;
|
||||
setInputAttributes(select, config);
|
||||
const groups = prepareSelectOptionGroups(config);
|
||||
for ( const g of groups ) {
|
||||
let parent = select;
|
||||
if ( g.group ) parent = _appendOptgroup(g.group, select);
|
||||
for ( const o of g.options ) _appendOption(o, parent);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* @typedef {Object} TextAreaInputConfig
|
||||
* @property {number} rows
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a `<textarea>` element for a StringField.
|
||||
* @param {FormInputConfig<string> & TextAreaInputConfig} config
|
||||
* @returns {HTMLTextAreaElement}
|
||||
*/
|
||||
export function createTextareaInput(config) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.name = config.name;
|
||||
textarea.textContent = config.value ?? "";
|
||||
if ( config.rows ) textarea.setAttribute("rows", String(config.rows));
|
||||
setInputAttributes(textarea, config);
|
||||
return textarea;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create an `<input type="text">` element for a StringField.
|
||||
* @param {FormInputConfig<string>} config
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createTextInput(config) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = config.name;
|
||||
input.setAttribute("value", config.value ?? "");
|
||||
setInputAttributes(input, config);
|
||||
return input;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
/* Helper Methods */
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Structure a provided array of select options into a standardized format for rendering optgroup and option elements.
|
||||
* @param {FormInputConfig & SelectInputConfig} config
|
||||
* @returns {{group: string, options: FormSelectOption[]}[]}
|
||||
*
|
||||
* @example
|
||||
* const options = [
|
||||
* {value: "bar", label: "Bar", selected: true, group: "Good Options"},
|
||||
* {value: "foo", label: "Foo", disabled: true, group: "Bad Options"},
|
||||
* {value: "baz", label: "Baz", group: "Good Options"}
|
||||
* ];
|
||||
* const groups = ["Good Options", "Bad Options", "Unused Options"];
|
||||
* const optgroups = foundry.applications.fields.prepareSelectOptionGroups({options, groups, blank: true, sort: true});
|
||||
*/
|
||||
export function prepareSelectOptionGroups(config) {
|
||||
|
||||
// Coerce values to string array
|
||||
let values = [];
|
||||
if ( (config.value === undefined) || (config.value === null) ) values = [];
|
||||
else if ( typeof config.value === "object" ) {
|
||||
for ( const v of config.value ) values.push(String(v));
|
||||
}
|
||||
else values = [String(config.value)];
|
||||
const isSelected = value => values.includes(value);
|
||||
|
||||
// Organize options into groups
|
||||
let hasBlank = false;
|
||||
const groups = {};
|
||||
for ( const option of (config.options || []) ) {
|
||||
let {group, value, label, disabled, rule} = option;
|
||||
|
||||
// Value
|
||||
if ( config.valueAttr ) value = option[config.valueAttr];
|
||||
if ( value !== undefined ) {
|
||||
value = String(value);
|
||||
if ( value === "" ) hasBlank = true;
|
||||
}
|
||||
|
||||
// Label
|
||||
if ( config.labelAttr ) label = option[config.labelAttr];
|
||||
label ??= value;
|
||||
if ( label !== undefined ) {
|
||||
if ( typeof label !== "string" ) label = label.toString();
|
||||
if ( config.localize ) label = game.i18n.localize(label);
|
||||
}
|
||||
|
||||
const selected = option.selected || isSelected(value);
|
||||
disabled = !!disabled;
|
||||
|
||||
// Add to group
|
||||
group ||= "";
|
||||
groups[group] ||= [];
|
||||
groups[group].push({type: "option", value, label, selected, disabled, rule})
|
||||
}
|
||||
|
||||
// Add groups into an explicitly desired order
|
||||
const result = [];
|
||||
if ( config.groups instanceof Array ) {
|
||||
for ( let group of config.groups ) {
|
||||
const options = groups[group] ?? [];
|
||||
delete groups[group];
|
||||
if ( config.localize ) group = game.i18n.localize(group);
|
||||
result.push({group, options});
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining groups
|
||||
for ( let [groupName, options] of Object.entries(groups) ) {
|
||||
if ( groupName && config.localize ) groupName = game.i18n.localize(groupName);
|
||||
result.push({group: groupName, options});
|
||||
}
|
||||
|
||||
// Sort options
|
||||
if ( config.sort ) {
|
||||
for ( const group of result ) group.options.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
|
||||
}
|
||||
|
||||
// A blank option always comes first
|
||||
if ( (typeof config.blank === "string") && !hasBlank ) result.unshift({group: "", options: [{
|
||||
value: "",
|
||||
label: config.localize ? game.i18n.localize(config.blank) : config.blank,
|
||||
selected: isSelected("")
|
||||
}]});
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create and append an option element to a parent select or optgroup.
|
||||
* @param {FormSelectOption} option
|
||||
* @param {HTMLSelectElement|HTMLOptGroupElement} parent
|
||||
* @internal
|
||||
*/
|
||||
function _appendOption(option, parent) {
|
||||
const { value, label, selected, disabled, rule } = option;
|
||||
if ( (value !== undefined) && (label !== undefined) ) {
|
||||
const o = document.createElement("option");
|
||||
o.value = value;
|
||||
o.innerText = label;
|
||||
if ( selected ) o.toggleAttribute("selected", true);
|
||||
if ( disabled ) o.toggleAttribute("disabled", true);
|
||||
parent.appendChild(o);
|
||||
}
|
||||
if ( rule ) parent.insertAdjacentHTML("beforeend", "<hr>");
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create and append an optgroup element to a parent select.
|
||||
* @param {string} label
|
||||
* @param {HTMLSelectElement} parent
|
||||
* @returns {HTMLOptGroupElement}
|
||||
* @internal
|
||||
*/
|
||||
function _appendOptgroup(label, parent) {
|
||||
const g = document.createElement("optgroup");
|
||||
g.label = label;
|
||||
parent.appendChild(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply standard attributes to all input elements.
|
||||
* @param {HTMLElement} input The element being configured
|
||||
* @param {FormInputConfig<*>} config Configuration for the element
|
||||
*/
|
||||
export function setInputAttributes(input, config) {
|
||||
input.toggleAttribute("required", config.required === true);
|
||||
input.toggleAttribute("disabled", config.disabled === true);
|
||||
input.toggleAttribute("readonly", config.readonly === true);
|
||||
input.toggleAttribute("autofocus", config.autofocus === true);
|
||||
if ( config.placeholder ) input.setAttribute("placeholder", config.placeholder);
|
||||
if ( "dataset" in config ) {
|
||||
for ( const [k, v] of Object.entries(config.dataset) ) {
|
||||
input.dataset[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user