forms.js

/**
 * Defines extensions of the native HTML `<select>` element which self-populate with different kinds of Flight Rising data. Available elements are registered as `fr-eyes`, `fr-colours`, `fr-breeds`, `fr-genes`, `fr-ages`, `fr-genders`, and `fr-elements`. See the tutorials for usage.
 *
 * Customized Built-in Elements are not natively supported in Safari, but the custom dropdowns should work in Safari anyway because the module loads a polyfill if CBIE support is not detected.
 * @module FRjs/forms
 * @tutorial 02-fr-forms
 * @requires module:FRjs/data
 */

// Polyfill customized built-in elements for safari
let supportsCBI = false;
try {
	document.createElement("div", {
		// eslint-disable-next-line getter-return
		get is() { supportsCBI = true; }
	});
} catch (e) { console.error(e); }
if (!supportsCBI) {
	await import("https://unpkg.com/@ungap/custom-elements/es.js");
}

import * as FR from "./data.js";

/** The message bus in a publisher-subscriber system.
 * @private */
class PubSub {
	/** A map containing channel keys, and lists of callback functions subscribed to those channels.
	 * @type {Map<string,Array<function(any)>>} */
	#subscribers = new Map();
	/** A map containing channel keys, and the last message sent to that channel.
	 * @type {Map<string,any>} */
	#messages = new Map();

	/** Send a message to everyone subscribed to the given channel.
	 * @param {string} channel The channel to send the message to.
	 * @param {any} message The message to send.
	 * @returns {function} A function which can be used to delete the last message sent to the given channel. */
	sendMessage(channel, message) {
		this.#messages.set(channel, message);
		if (!this.#subscribers.has(channel)) {
			this.#subscribers.set(channel, []);
		}
		for (const callback of this.#subscribers.get(channel)) {
			callback(message);
		}
		return () => {
			this.#messages.delete(channel);
		};
	}

	/** Check the last message sent to a given channel.
	 * @param {string} channel The channel to view the history of. */
	checkMessage(channel) {
		return this.#messages.get(channel);
	}

	/** Subscribe to messages sent to a specific channel, and also receive the last message sent to the channel if there is one.
	 * @param {string} channel The channel to subscribe to
	 * @param {function(any)} callback The function to call when a message is sent to this channel.
	 * @returns {function} A function that can be called to unsubscribe this callback from this channel. */
	subscribe(channel, callback) {
		if (channel && callback) {
			if (!this.#subscribers.has(channel)) {
				this.#subscribers.set(channel, []);
			}

			const subList = this.#subscribers.get(channel);
			if (subList.indexOf(callback) < 0) {
				subList.push(callback);
			}

			const message = this.#messages.get(channel);
			if (message) {
				callback(message);
			}

			return () => {
				const idx = subList.indexOf(callback);
				if (idx != null && idx > -1) {
					subList.splice(idx, 1);
				}
			};
		}
	}
}

/** The Pub/Sub message bus for the BreedSelect/GeneSelect link.
 * @private */
const bgPubSub = new PubSub();

/** Base class for very simple self-populating dropdowns with no extra behaviour. When extending:
 * - Override `get dataArray()` to return an array of objects with a name property; these will become options in the dropdown.
 * - Optionally, override `extraOptionInit(option, obj)` to do something extra to each option after it's created, but before it's added to the option list.
 * @private */
class BasicSelect extends HTMLSelectElement {
	#isPopulated = false;

	get dataArray() { return []; }

	extraOptionInit(option, obj) { }

	connectedCallback() {
		if (this.#isPopulated) { return; }
		this.#isPopulated = true;

		for (const [i, elt] of this.dataArray.entries()) {
			const op = document.createElement("option");
			[op.value, op.text] = [i, elt.name];
			this.extraOptionInit(op, elt);
			this.add(op);
		}
	}
}

/** A customized `<select>` element which self-populates with options representing Flight Rising's dragon ages; i.e., Dragon and Hatchling. Registered as `fr-ages`.
 * @tutorial 02-fr-forms */
class AgeSelect extends BasicSelect {
	get dataArray() { return FR.AGES; }
}

/** A customized `<select>` element which self-populates with options representing all Flight Rising's dragon genders; i.e., Male and Female. Registered as `fr-genders`.
 * @tutorial 02-fr-forms */
class GenderSelect extends BasicSelect {
	get dataArray() { return FR.GENDERS; }
}

/** A customized `<select>` element which self-populates with options representing all of Flight Rising's flight elements, in the order they appear on-site. Registered as `fr-elements`.
 * @tutorial 02-fr-forms */
class ElementSelect extends BasicSelect {
	get dataArray() { return FR.ELEMENTS; }
}

/** A customized `<select>` element which self-populates with options representing all of Flight Rising's eye types, in order of increasing rarity. Registered as `fr-eyes`.
 * @tutorial 03-eyeselect */
class EyeSelect extends BasicSelect {
	get dataArray() { return FR.EYES; }

	extraOptionInit(op, obj) {
		if (obj.probability === 0) {
			op.dataset.notNat = true;
			if (this.hasAttribute("only-natural")) {
				op.disabled = true;
				op.style = "display: none;";
			}
		}
	}

	static get observedAttributes() { return ["only-natural"] }

	attributeChangedCallback(name) {
		if (this.hasAttribute(name)) {
			for (const op of this) {
				if (op.dataset.notNat === "true") {
					op.disabled = true;
					op.style = "display: none;";
				}
			}
		}
		else {
			for (const op of this) {
				op.removeAttribute("disabled");
				op.removeAttribute("style");
			}
		}
	}
}

/** A customized `<select>` element which self-populates with options representing all of Flight Rising's colours, in order of the on-site colour wheel. Registered as `fr-colours`.
 * @tutorial 04-colourselect */
class ColourSelect extends BasicSelect {
	/** Stores text colours for each possible colour option.
	 * @private
	 * @type {Map.<string, string>} */
	static #styleCache = new Map();

	get dataArray() { return FR.COLOURS; }

	extraOptionInit(op, obj) {
		if (!ColourSelect.#styleCache.has(op.value)) {
			ColourSelect.#styleCache.set(op.value, `background:#${obj.hex};color:#${ColourSelect.#textColourForBg(obj.hex)}`);
		}
		if (!this.hasAttribute("no-opt-colours")) {
			op.style = ColourSelect.#styleCache.get(op.value);
		}
	}

	static get observedAttributes() { return ["no-opt-colours"]; }

	attributeChangedCallback(name) {
		if (this.hasAttribute(name)) {
			for (const op of this) {
				op.removeAttribute("style");
			}
		} else if (!this.hasAttribute(name)) {
			for (const op of this) {
				op.style = ColourSelect.#styleCache.get(op.value);
			}
		}
	}

	/** Determines whether text placed on the given background colour should be black or white for the best readability.
	 * @private
	 * @param {string} bgHex A non-prefixed 6-digit hex colour code.
	 * @returns {string} Whichever of "000" or "fff" is easier to read when placed on the given background colour. */
	static #textColourForBg(bgHex) {
		// Convert to RGB
		bgHex = +(`0x${bgHex}`);
		const r = bgHex >> 16,
			g = bgHex >> 8 & 255,
			b = bgHex & 255;

		// Perceived brightness equation from http://alienryderflex.com/hsp.html
		const perceivedBrightness = Math.sqrt(
			0.299 * (r * r)
			+ 0.587 * (g * g)
			+ 0.114 * (b * b)
		);

		if (perceivedBrightness > 110) {
			return "000";
		}
		return "fff";
	}
}

/** A customized `<select>` element which self-populates with options representing all of Flight Rising's breeds, separated into Modern and Ancient `<optgroup>`s which are each ordered alphabetically. Registered as `fr-breeds`.
 *
 * These elements are the publishers of a Pub/Sub relationship with {@link module:FRjs/forms~GeneSelect GeneSelect}s.
 *
 * @tutorial 05-breedselect */
class BreedSelect extends HTMLSelectElement {
	#prevValue;
	#isPopulated = false;
	#deleteMsg = () => { };

	connectedCallback() {
		if (!this.isConnected) { return; }

		// Initial population + event listener
		if (!this.#isPopulated) {
			this.#isPopulated = true;

			const modern = document.createElement("optgroup"),
				ancient = document.createElement("optgroup");
			modern.label = "Modern";
			ancient.label = "Ancient";

			for (const [i, elt] of FR.BREEDS.entries()) {
				const opt = document.createElement("option");
				opt.value = i;
				opt.text = elt.name;
				if (elt.type === "M") {
					modern.append(opt);
				} else {
					ancient.append(opt);
				}
			}
			this.append(modern, ancient);

			this.addEventListener("change", () => {
				if (!FR.areBreedsCompatible(this.value, this.#prevValue)) {
					this.#prevValue = this.value;
					bgPubSub.sendMessage(this.id, this.value);
				}
			});
		}

		// Send message every time we're reattached
		this.#prevValue = this.value;
		this.#deleteMsg = bgPubSub.sendMessage(this.id, this.value);
	}

	disconnectedCallback() {
		this.#deleteMsg();
	}
}

/** A customized `<select>` element which self-populates with options representing Flight Rising's genes, ordered alphabetically. Registered as `fr-genes`.
 *
 * These elements can optionally be the subscribers in a Pub/Sub relationship with {@link module:FRjs/forms~BreedSelect BreedSelect}s.
 *
 * @tutorial 06-geneselect */
class GeneSelect extends HTMLSelectElement {
	#isPopulated = false;
	#defaultName = "basic";
	#slot = "primary";
	/** @type {string?} */
	#breedName;
	/** @type {string?} */
	#breedSelectID;
	#unsubscribe = () => { };

	#resubscribe(newChannel) {
		this.#unsubscribe?.();
		this.#unsubscribe = bgPubSub.subscribe(newChannel, this.#repopulate.bind(this));
	}

	static get observedAttributes() { return ["slot", "breed", "breed-name", "default"]; }

	attributeChangedCallback(name, oldValue, newValue) {
		if (name === "default") {
			this.#defaultName = newValue?.toLowerCase() ?? "basic";
		} else if (name === "breed") {
			this.#breedSelectID = newValue;
			this.#resubscribe(newValue);
		} else if (name === "breed-name") {
			this.#breedName = newValue?.toLowerCase();
			if (!this.#breedSelectID) { this.#repopulate(this.#breedName); }
		} else if (name === "slot") {
			this.#slot = newValue ?? "primary";
			this.#repopulate();
		}
	}

	connectedCallback() {
		this.#isPopulated = true;
		this.#resubscribe(this.#breedSelectID);
	}

	disconnectedCallback() {
		if (this.#unsubscribe) {
			this.#unsubscribe();
		}
	}

	#repopulate(breedVal) {
		if (!this.isConnected || !this.#isPopulated) {
			return;
		}

		const oldSelectedVal = this.value;
		let oldVal, defVal;

		if (!breedVal) {
			breedVal = bgPubSub.checkMessage(this.#breedSelectID)
				?? (
					this.#breedName
						? FR.BREEDS.findIndex(x => x.name.toLowerCase() === this.#breedName)
						: null
				);
		}

		for (let i = this.length - 1; i >= 0; i--) {
			if (this[i].dataset.auto) { this.remove(i); }
		}

		for (const op of this.options) {
			if (op.text.toLowerCase() === this.#defaultName) {
				defVal = op.value;
				break;
			}
		}

		for (const i of FR.genesForBreed(this.#slot, breedVal)) {
			const name = FR.GENES[this.#slot][i].name;
			if (!oldVal && i.toString() === oldSelectedVal) {
				oldVal = i;
			}
			if (!defVal && name.toLowerCase() === this.#defaultName) {
				defVal = i;
			}
			const op = document.createElement("option");
			[op.value, op.text] = [i, name];
			op.dataset.auto = true;
			this.add(op);
		}
		this.value = oldVal ?? defVal ?? this[0].value;
	}
}

customElements.define("fr-ages", AgeSelect, { extends: "select" });
customElements.define("fr-genders", GenderSelect, { extends: "select" });
customElements.define("fr-elements", ElementSelect, { extends: "select" });
customElements.define("fr-eyes", EyeSelect, { extends: "select" });
customElements.define("fr-colours", ColourSelect, { extends: "select" });
customElements.define("fr-breeds", BreedSelect, { extends: "select" });
customElements.define("fr-genes", GeneSelect, { extends: "select" });