🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

Greasy Fork is available in English.

📂 缓存分发状态(共享加速已生效)
🕒 页面同步时间:2026/01/08 22:45:19
🔄 下次更新时间:2026/01/08 23:45:19
手动刷新缓存

USPS Address Validation - Common

Library used between the Add/Edit page and the View page.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greasyfork.org/scripts/555040/1729712/USPS%20Address%20Validation%20-%20Common.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

// ==UserScript==
// @name         USPS Address Validation - Common
// @namespace    https://github.com/nate-kean/
// @version      2026.01.07
// @description  Library used between the Add/Edit page and the View page.
// @author       Nate Kean
// @match        https://jamesriver.fellowshiponego.com/members/add*
// @match        https://jamesriver.fellowshiponego.com/members/edit/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=fellowshiponego.com
// @grant        none
// @license      MIT
// ==/UserScript==

// @ts-check

document.head.insertAdjacentHTML("beforeend", `
	<style id="jrc-address-validator-css">
		.address-panel > .panel-heading {
			background-color: unset !important;

			& > .panel-title {
				padding-right: 50px;
				display: flex;
				justify-content: space-between;
			}
		}

		.address-panel > .panel-heading:has(.panel-title) {
			width: 50% !important;
		}

		button.jrc-address-validation-indicator {
			float: right;
			width:  32px;
			height: 32px;
			text-align: center;
			padding: 0;
			border: none;
			margin-top: -4px;

			& > i {
				margin-top: 3px;
				font-size: 16px;
				font-weight: 600;
			}

			& > i.fa-check {
				color: #00c853;
			}
			& > i.fa-exclamation {
				color: #ff8f00;
				background-color: hsla(0, 0%, 100%, .1);
				border-radius: 6px;
				transition: background-color 100ms;
			}
			& > i.fa-times {
				color: #c84040;
				/* This icon is just smaller than the others for some reason */
				font-size: 20px;
			}
		}

		.address-2-header > .jrc-address-validation-indicator {
			margin-right: 20px;
		}

		#addresslabel1_chosen {
			scroll-margin-top: 104px;
		}

		body > .tooltip > .tooltip-inner {
			max-width: 260px !important;
		}
	</style>
`);


/**
 * @typedef {Object} QueriedAddress
 * @property {string} streetAddress
 * @property {string} city
 * @property {string} state
 * @property {string} zip
 * @property {string} country
 */

/**
 * @typedef {Object} CanonicalAddress
 * @property {string} streetAddress
 * @property {string} city
 * @property {string} state
 * @property {string} zip5
 * @property {string} zip4
 */

/**
 * Exactly the same as a Canonical Address, but I hate the way USPS names the
 * ZIP code fields so I only use this as much as I have to
 * @typedef {Object} USPSAddress
 * @property {string} streetAddress
 * @property {string} city
 * @property {string} state
 * @property {string} ZIPCode
 * @property {string} ZIPPlus4
 */


/**
 * @typedef {{
 * 		code: typeof Validator.Code.MATCH;
 * 		address: CanonicalAddress;
 * 	}
 * 	| {
 * 		code: typeof Validator.Code.CORRECTION;
 * 		addrHTML: string;
 * 		note: string | null;  // non-empty
 * 		correctionCount: number;  // non-zero
 * 		address: CanonicalAddress;
 * 	}
 * 	| {
 * 		code: typeof Validator.Code.WARNING;
 * 		note: string;  // non-empty
 * 	}
 * 	| { code: typeof Validator.Code.NOT_FOUND }
 * 	| { code: typeof Validator.Code.FOREIGN }
 * 	| {
 * 		code: typeof Validator.Code.VAL_ERROR;
 * 		note: string;  // non-empty
 * 	}
 * 	| {
 * 		code: typeof Validator.Code.PROG_ERROR;
 * 		note: string;  // non-empty
 * 	}
 * 	| { code: typeof Validator.Code.LOADING }
 * 	| { code: typeof Validator.Code.EMPTY }
 * 	| {
 * 		code: typeof Validator.Code.NOT_IMPL;
 * 		note: string;  // non-empty
 * 	}
 * 	| { code: typeof Validator.Code.NOT_FOUND }
 *  | {
 * 		code: typeof Validator.Code.CHECK_FAILED;
 * 		note: string;  // non-empty
 * }
 * 	| { code: typeof Validator.Code.MARKED_INVALID }} ValResult
 */


/**
 * @typedef {{
 * 		code: typeof Validator.ValidateCode.SUCCESS;
 * 		result: ValResult;
 * } | {
 * 		code: typeof Validator.ValidateCode.CANCELLED;
 * }} ValResultResult
 */


/**
 * @param {number} ms
 * @returns {Promise<void>}
 */
function delay(ms) {
	return new Promise((resolve) => setTimeout(resolve, ms));
}


/**
 * @param {string} str
 * @returns {string}
 */
function toTitleCase(str) {
	str = str
		.replace(
			/\w\S*/g,
			text => {
				return text.charAt(0).toUpperCase()
					+ text.substring(1).toLowerCase();
			},
		)
		.replace(/Po Box/i, "PO Box")
		// Special case for repeated letters: e.g.,
		// "CC" remains all caps
		.replace(/\b(.)\1+\b/gi, text => text.toUpperCase());
	// Special case for McNames
	if (str.startsWith("Mc")) {
		str = str.substring(0, 2) + str[2].toUpperCase() + str.substring(3);
	}
	return str;
}


/**
 * @template {Element} [El=Element]
 * @param {Element | Document} el
 * @param {string} selector
 * @param {{ logError: boolean }} options
 * @returns {El}
 */
function tryQuerySelector(el, selector, options={ logError: true }) {
	/** @type {El?} */
	const maybeEl = el.querySelector(selector);
	if (maybeEl === null) {
		if (options.logError) console.error(el);
		throw new Error(`Failed to find '${selector}' in given element`);
	}
	return maybeEl;
}


class Validator {
	static Code = Object.freeze({
		__proto__: null,
		MATCH: 0,
		CORRECTION: 1,
		WARNING: 2,
		NOT_FOUND: 3,
		FOREIGN: 4,
		VAL_ERROR: 5,
		PROG_ERROR: 6,
		LOADING: 7,
		EMPTY: 8,
		NOT_IMPL: 9,
		CHECK_FAILED: 10,
		MARKED_INVALID: 11,
	});

	// Here lies Nate's sanity
	static ValidateCode = Object.freeze({
		__proto__: null,
		SUCCESS: 100,
		CANCELLED: 101,
	});

	static #API_ROOT = "https://addrval.onrender.com";

	static #DEFAULT_BACKOFF = 4000;
	static #backoff = Validator.#DEFAULT_BACKOFF;

	/** @type {AbortController?} */
	#abortController;

	/** @type {ValResult?} */
	#lastResult;

	constructor() {
		this.#lastResult = null;
		this.#abortController = null;
		window.addEventListener("unload", () => this.#abortController?.abort());
	}

	/**
	 * Call when there is a new address to validate.
	 * @param {Readonly<Indicator>} indicator
	 * @param {Readonly<QueriedAddress>} address
	 * @param {Readonly<ValResult>?} heuristic
	 * @returns {Promise<void>}
	 */
	async onNewAddressQuery(indicator, address, heuristic=null) {
		// Any address request currently going is now stale, so cancel it
		this.#abortController?.abort();

		// rrrrrrrrrrrrrrgggggghhhhhhh. rrrrrrrrrrrrrrrrrggggggggggggghhhhhhhhh!
		if (heuristic !== null) {
			indicator.onValidationResult(heuristic);
			return;
		}

		// Check if autofill was clicked
		// (last thing we suggested is what's in the address panel now)
		if (
			this.#lastResult !== null
			&& this.#lastResult.code === Validator.Code.CORRECTION
			&& this.#lastResult.address.streetAddress === address.streetAddress
			&& this.#lastResult.address.city === address.city
			&& this.#lastResult.address.state === address.state
		) {
			const zipParts = address.zip.split("-");
			if (
				this.#lastResult.address.zip5 === zipParts[0]
				&& this.#lastResult.address.zip4 === zipParts[1]
			) {
				// Last address matches the new queried one perfectly; we can
				// skip asking USPS about it.
				if (this.#lastResult.note === null) {
					// USPS didn't have any other problems with it:
					// it's a match!
					console.debug(" ** Correction used; availed MATCH");
					indicator.onValidationResult({
						code: Validator.Code.MATCH,
						address: this.#lastResult.address,
					});
				} else {
					// USPS still left a note; include it.
					console.debug(" ** Correction used; availed WARNING");
					indicator.onValidationResult({
						code: Validator.Code.WARNING,
						note: this.#lastResult.note,
					});
				}
				return;
			}
		}

		// Check cache. Maybe this address has been checked in this browser
		// before
		const cached = Validator.#getFromCache(address);
		if (cached !== null) {
			this.#lastResult = cached;
			indicator.onValidationResult(cached);
			console.debug(` ** Retrieved from cache: ${JSON.stringify(cached)}`);
			return;
		};

		const result = await this.#validate(address);
		this.#abortController = null;
		console.debug(` ** Fetched new: ${JSON.stringify(result)}`);
		if (result === Validator.ValidateCode.CANCELLED) return;
		Validator.#sendToCache(address, result);
		this.#lastResult = result;
		indicator.onValidationResult(result);
	}

	/**
	 * @param {Readonly<QueriedAddress>} address
	 * @returns {Promise<ValResult | typeof Validator.ValidateCode.CANCELLED>}
	 */
	async #validate(address) {
		let { streetAddress, city, state, zip } = address;

		const earlyResult = Validator.#clientSideChecks(address);
		if (earlyResult !== null) return earlyResult;

		await Validator.#pickUpTimeout();

		// Normalize and format the address to prepare to send to USPS
		streetAddress = toTitleCase(streetAddress);
		// #'s confuse the API. Official USPS addresses will never contain #.
		// USPS will correct "apartment" to "Apt", or "Unit", or whatever else
		// it's supposed to be. We could replace #'s with "Apt " to keep from
		// confusing the API, but we also want USPS to correct it, so we
		// normalize them to "apartment".
		streetAddress = streetAddress.replaceAll("#", "apartment ");
		city = toTitleCase(city);
		/** @type {Partial<USPSAddress>} */
		let query = { streetAddress, city, state };
		const zipParts = zip?.split("-") ?? [];
		// Only include the ZIP code if it's properly formatted (as a 5 or 5+4).
		// Otherwise the API throws an HTTP 400
		if (zipParts[0]?.length === 5) {
			query.ZIPCode = zipParts[0];
			if (zipParts[1]?.length === 4) {
				query.ZIPPlus4 = zipParts[1];
			}
		}
		const patch = Validator.preflightPatches(address);
		query = {...query, ...patch};

		let response;
		try {
			this.#abortController = new AbortController();
			response = await fetch(
				`${Validator.#API_ROOT}/?${new URLSearchParams(query)}`, {
					signal: this.#abortController.signal,
				}
			);
		} catch (err) {
			if (err instanceof DOMException && err.name === "AbortError") {
				return Validator.ValidateCode.CANCELLED;
			}
			let note;
			if (err instanceof Error) {
				console.error(err);
				note = err.message;
			} else {
				note = String(err);
			}
			return { code: Validator.Code.PROG_ERROR, note };
		}

		let { result, json } = await this.#parseStatus(response, address);
		if (result !== null) return result;

		json = json ?? await response.json();
		console.debug(json);
		return Validator.#makeCorrectionResult(json, address, zipParts, patch);
	}

	static async #pickUpTimeout() {
		// Handle being timed out on a previous page
		const prevBackoffDateStr = window.localStorage.getItem("jrc retry");
		if (prevBackoffDateStr === null) return;
		const prevBackoffDate = new Date(prevBackoffDateStr);
		const prelimBackoffMS = (
			prevBackoffDate.getMilliseconds()
			- (new Date().getMilliseconds())
		);
		await delay(prelimBackoffMS);
		window.localStorage.removeItem("jrc retry");
	}

	/**
	 * @param {Readonly<Response>} response
	 * @param {Readonly<QueriedAddress>} address
	 * (TS infers a more detailed type)
	 * returns {Promise<{result: ValResult?, json: unknown?}>}
	 */
	async #parseStatus(response, address) {
		console.debug(` ** HTTP ${response.status}`);
		switch (response.status) {
			case 200:
				const json = await response.json();
				if ("error" in json) {
					return {
						result: {
							code: Validator.Code.VAL_ERROR,
							note: json.error.message ?? "Unknown error",
						},
						json,
					}
				}
				return { result: null, json };
			case 400: {
				const json = await response.json();
				if (json?.error?.message === "Address Not Found") {
					return {
						result: { code: Validator.Code.NOT_FOUND },
						json: null,
					};
				}
				console.error(json);
				return {
					result: {
						code: Validator.Code.PROG_ERROR,
						note: "Service returned status code 400",
					},
					json: null,
				};
			}
			case 404:
				return {
					result: { code: Validator.Code.NOT_FOUND },
					json: null,
				};
			case 429:
				return {
					result: {
						code: Validator.Code.PROG_ERROR,
						note: "Rate limit exceeded. Please wait and try again.",
					},
					json: null,
				};
			case 503:
				return {
					result: await Validator.#retry(
						() => this.#validate(address)
					),
					json: null,
				};
			default:
				console.error(await response.json());
				return {
					result: {
						code: Validator.Code.PROG_ERROR,
						note: `USPS returned status code ${response.status}`,
					},
					json: null,
				};
		}
	}

	/**
	 * @param {Readonly<any>} json
	 * @param {Readonly<QueriedAddress>} query
	 * @param {Readonly<string[]>} zipParts
	 * @param {Readonly<Partial<QueriedAddress>>} patch
	 * @returns {ValResult}
	 */
	static #makeCorrectionResult(json, query, zipParts, patch) {
		let note = "";
		let correctionCount = 0;
		const code = json.corrections[0]?.code || json.matches[0]?.code;
		switch (code) {
			case "31":
				break;
			case "32":
				note = "Missing or incorrectly-formatted apartment, suite, or "
				     + "box number.";
				correctionCount++;
				break;
			case "22":
				note = json.corrections[0].text;
				correctionCount++;
				break;
			default:
				return {
					code: Validator.Code.NOT_IMPL,
					note: `Status code ${code} not implemented`,
				};
		}
		/** @type {Readonly<CanonicalAddress>} */
		const canon = {
			streetAddress: toTitleCase(
				`${json.address.streetAddress} ${json.address.secondaryAddress}`
			).trim(),
			city: toTitleCase(json.address.city),
			state: json.address.state,
			zip5: json.address.ZIPCode,
			// Plus-Four should be blank if missing apt number
			zip4: code === "32" ? "" : json.address.ZIPPlus4,
		};
		let addrHTML = "";
		if (
			canon.streetAddress === query.streetAddress
			&& !("streetAddress" in patch)
		) {
			addrHTML += query.streetAddress;
		} else {
			addrHTML += `<strong>${canon.streetAddress}</strong>`;
			correctionCount++;
		}
		addrHTML += "<br>";
		if (canon.city === query.city && !("city" in patch)) {
			addrHTML += query.city;
		} else {
			addrHTML += `<strong>${canon.city}</strong>`;
			correctionCount++;
		}
		addrHTML += ", ";
		if (canon.state === query.state && !("state" in patch)) {
			addrHTML += query.state;
		} else {
			addrHTML += `<strong>${canon.state}</strong>`;
			correctionCount++;
		}
		addrHTML += " ";
		if (
			canon.zip5 === zipParts[0]
			&& canon.zip4 === zipParts[1]
			&& !("zip" in patch)
		) {
			addrHTML += `${canon.zip5}-${canon.zip4}`;
		} else {
			addrHTML += `<strong>${canon.zip5}-${canon.zip4}</strong>`;
			correctionCount++;
		}
		switch (correctionCount) {
			case 0:
				return { code: Validator.Code.MATCH, address: canon};
			case 1:
				if (code === "32") {
					// "Missing or incorrect apartment number"
					return { code: Validator.Code.WARNING, note };
				}
			// [explicit fallthrough]
			default:
				return {
					code: Validator.Code.CORRECTION,
					addrHTML,
					note: note || null,  // Please no empty strings here
					correctionCount: correctionCount,
					address: canon,
				};
		}
	}

	/**
	 * Do some trivial checks that we don't need the API for clientside.
	 * @param {Readonly<QueriedAddress>} address
	 * @returns {{ code: typeof Validator.Code.EMPTY }
	 * 		| { code: typeof Validator.Code.FOREIGN }
	 * 		| null}
	 */
	static #clientSideChecks({ streetAddress, city, state, zip, country }) {
		// Missing required field
		if (!streetAddress || !city || !state) {
			return { code: Validator.Code.EMPTY };
		}

		// Non-US country code (except blank is fine)
		if (country.length > 0 && country.toUpperCase() !== "US") {
			return { code: Validator.Code.FOREIGN };
		}

		return null;
	}

	/**
	 * Change these things on the request before it goes out.
	 * TODO: the way this system is implemented makes these NOT show as
	 * corrections in the pop-up, but they should.
	 * @param {Readonly<QueriedAddress>} address
	 * @returns {Partial<QueriedAddress>}
	 */
	static preflightPatches({ streetAddress, city, state, zip, country }) {
		/** @type {Partial<QueriedAddress>} */
		const patch = {};

		// State abbreviation not capitalized
		const stateUpper = state.toUpperCase();
		if (state !== stateUpper) {
			patch.state = stateUpper;
		}

		// Country code not capitalized
		const countryUpper = country.toUpperCase();
		if (country.length === 2 && country !== "US") {
			patch.country = countryUpper;
		}

		if (
			["USA", "United States", "usa", "united states"].includes(country)
		) {
			patch.country = "US";
		}

		return patch;
	}

	/**
	 * @param {Readonly<QueriedAddress>} address
	 * @returns {string}
	 */
	static #serializeAddress(address) {
		return `jrc addr ${JSON.stringify(address)}`;
	}

	/**
	 * @param {Readonly<QueriedAddress>} address
	 * @returns {ValResult?}
	 */
	static #getFromCache(address) {
		const key = Validator.#serializeAddress(address);
		const value = window.localStorage.getItem(key);
		if (value === null) {
			console.debug(" ** Cache miss:", key);
			return null;
		}
		console.debug(" ** Cache hit:", key);
		return JSON.parse(value);
	}

	/**
	 * @param {Readonly<QueriedAddress>} address
	 * @param {Readonly<ValResult>} result
	 * @returns {void}
	 */
	static #sendToCache(address, result) {
		if (
			result.code === Validator.Code.PROG_ERROR
			|| result.code === Validator.Code.NOT_IMPL
		) return;
		const key = Validator.#serializeAddress(address);
		const value = JSON.stringify(result);
		window.localStorage.setItem(key, value);
	}

	/**
	 * Exponential backoff retry
	 * @template T
	 * @param {() => Promise<T>} callback
	 * @returns {Promise<T>}
	 */
	static async #retry(callback) {
		const timeoutDate = new Date();
		timeoutDate.setMilliseconds(
			timeoutDate.getMilliseconds() + Validator.#backoff
		);
		window.localStorage.setItem("jrc retry", timeoutDate.toISOString());
		await delay(Validator.#backoff);
		Validator.#backoff **= 2;
		console.debug(Validator.#backoff);
		const promise = callback();
		Validator.#backoff = Validator.#DEFAULT_BACKOFF;
		window.localStorage.removeItem("jrc retry");
		return await promise;
	}
}


class Indicator {
	#icon;
	#isOnTypingCooldown;
	#buttonJQ;

	/**
	 * @param {Node} parent
	 */
	constructor(parent) {
		/** @type {ValResult} */
		this.status = { code: Validator.Code.LOADING };
		this.#isOnTypingCooldown = false;

		/** @type {HTMLButtonElement} */
		this.button = document.createElement("button");
		this.button.classList.add("jrc-address-validation-indicator");
		this.button.setAttribute("data-toggle", "tooltip");
		this.button.setAttribute("data-placement", "auto");
		this.button.setAttribute("data-html", "true");
		this.button.setAttribute("data-container", "body");
		this.button.type = "button";
		this.button.disabled = true;
		this.#buttonJQ = $(this.button).tooltip();

		this.#icon = document.createElement("i");
		this.#setIcon("fal", "fa-spinner-third", "fa-spin");
		this.button.appendChild(this.#icon);

		parent.appendChild(this.button);
	}

	/**
	 * @param {Readonly<ValResult>} result
	 * @returns {void}
	 */
	onValidationResult(result) {
		// The user had kept typing, so this result is now stale and should not
		// be displayed!
		if (this.#isOnTypingCooldown) return;

		let tooltipContent = "";

		this.#icon.classList.remove("fa-spinner-third", "fa-spin");
		this.button.disabled = true;
		switch (result.code) {
			case Validator.Code.LOADING:
				this.#setIcon("fa-spinner-third", "fa-spin");
				tooltipContent = "";
				break;
			case Validator.Code.MATCH:
				this.#setIcon("fa-check");
				tooltipContent = "USPS&thinsp;—&thinsp;Verified valid";
				break;
			case Validator.Code.CORRECTION: {
				this.#setIcon("fa-exclamation");
				const s = result.correctionCount > 1 ? "s" : "";
				tooltipContent = (
					`USPS&thinsp;—&thinsp;Correction${s} suggested:<br>`
					+ `${result.addrHTML}`
				);
				if (result.note !== null) {
					tooltipContent += `<br>${result.note}`;
				}
				this.button.disabled = false;
				break;
			}
			case Validator.Code.WARNING:
				this.#setIcon("fa-exclamation");
				tooltipContent =
					`USPS&thinsp;—&thinsp;Warning:<br>${result.note}`;
				break;
			case Validator.Code.NOT_FOUND:
				this.#setIcon("fa-times");
				tooltipContent = "USPS&thinsp;—&thinsp;Address not found";
				break;
			case Validator.Code.FOREIGN:
				this.#setIcon("fa-circle");
				tooltipContent =
					"USPS validation skipped: incompatible country";
				break;
			case Validator.Code.EMPTY:
				this.#setIcon("fa-circle");
				tooltipContent = "";
				break;
			case Validator.Code.VAL_ERROR:
				this.#setIcon("fa-times");
				tooltipContent = `USPS&thinsp;—&thinsp;${result.note}`;
				break;
			case Validator.Code.PROG_ERROR:
				this.#setIcon("fa-times");
				tooltipContent = `ERROR: ${result.note}. Contact Nate`;
				break;
			case Validator.Code.NOT_IMPL:
				this.#setIcon("fa-times");
				tooltipContent = `ERROR: ${result.note}. Contact Nate`;
				break;
			case Validator.Code.CHECK_FAILED:
				this.#setIcon("fa-times");
				tooltipContent = result.note;
				break;
			case Validator.Code.MARKED_INVALID:
				this.#setIcon("fa-circle");
				tooltipContent = (
					"USPS validation skipped: Address Validation flag is set"
					+ 'to "INVALID ADDRESS"'
				);
				break;
			default:
				this.#setIcon("fa-times");
				tooltipContent = "PLUGIN ERROR: contact Nate";
				console.error("Unexpected result code in:", result);
				break;
		}
		this.button.setAttribute("data-original-title", tooltipContent);
		// Refresh the tooltip otherwise it will keep showing the old info
		if (this.#buttonJQ.is(":hover")) this.#buttonJQ.tooltip("show");
		this.status = result;
	}

	/**
	 * Set an icon class while removing all the other icon classes.
	 * @param  {...string} classNames
	 */
	#setIcon(...classNames) {
		console.debug(classNames);
		this.#icon.classList.remove(
			"fa-spinner-third", "fa-spin",
			"fa-check",
			"fa-exclamation",
			"fa-times",
			"fa-circle",
		);
		this.#icon.classList.add(...classNames);
	}

	onTypingStart() {
		this.#isOnTypingCooldown = true;
		this.#setIcon("fa-spinner-third", "fa-spin");
		this.button.removeAttribute("data-original-title");
		this.button.disabled = true;
	}

	onTypingEnd() {
		this.#isOnTypingCooldown = false;
	}

	onEmptyField() {
		this.#isOnTypingCooldown = false;
		this.#setIcon("fa-circle");
	}
}