diff --git a/frontend/src/styles/inputs.scss b/frontend/src/styles/inputs.scss
index 3d38c41b4..9ca26de2f 100644
--- a/frontend/src/styles/inputs.scss
+++ b/frontend/src/styles/inputs.scss
@@ -15,6 +15,25 @@ textarea {
}
}
+.textareaWithCounter {
+ position: relative;
+
+ .char-counter {
+ position: absolute;
+ top: -1.75rem;
+ right: 0.25rem;
+ color: var(--sub-color);
+ -webkit-user-select: none;
+ user-select: none;
+ &.error {
+ color: var(--error-color);
+ }
+ &.warning {
+ color: color-mix(in srgb, var(--text-color) 50%, var(--error-color) 50%);
+ }
+ }
+}
+
textarea {
resize: vertical;
}
diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss
index f98d85b9a..2b46af9bd 100644
--- a/frontend/src/styles/popups.scss
+++ b/frontend/src/styles/popups.scss
@@ -937,17 +937,6 @@
line-height: 1.2rem;
min-height: 5rem;
}
- .characterCount {
- position: absolute;
- top: -1.75rem;
- right: 0.25rem;
- color: var(--sub-color);
- -webkit-user-select: none;
- user-select: none;
- &.red {
- color: var(--error-color);
- }
- }
}
}
#apeKeysModal {
@@ -1167,18 +1156,6 @@
line-height: 1.2rem;
min-height: 5rem;
}
-
- .characterCount {
- position: absolute;
- top: -1.75rem;
- right: 0.25rem;
- color: var(--sub-color);
- -webkit-user-select: none;
- user-select: none;
- &.red {
- color: var(--error-color);
- }
- }
}
}
@@ -1206,18 +1183,6 @@
line-height: 1.2rem;
min-height: 5rem;
}
-
- .characterCount {
- position: absolute;
- top: -1.75rem;
- right: 0.25rem;
- color: var(--sub-color);
- -webkit-user-select: none;
- user-select: none;
- &.red {
- color: var(--error-color);
- }
- }
}
}
diff --git a/frontend/src/ts/elements/character-counter.ts b/frontend/src/ts/elements/character-counter.ts
new file mode 100644
index 000000000..b118cd59a
--- /dev/null
+++ b/frontend/src/ts/elements/character-counter.ts
@@ -0,0 +1,53 @@
+export class CharacterCounter {
+ private textareaElement: JQuery
;
+ private parentElement: JQuery;
+ private counterElement: JQuery;
+ private maxLength: number;
+
+ constructor(textareaElement: JQuery, maxLength: number) {
+ this.textareaElement = textareaElement;
+ this.maxLength = maxLength;
+
+ this.textareaElement.attr("maxlength", this.maxLength.toString());
+
+ // Wrap the textarea element in a div if not already wrapped
+ if (!this.textareaElement.parent().hasClass("textareaWithCounter")) {
+ $(this.textareaElement).wrap(``);
+ }
+ this.parentElement = $(this.textareaElement).parent(".textareaWithCounter");
+
+ // Create the counter element if it doesn't exist
+ if (this.parentElement.find(".char-counter").length === 0) {
+ this.counterElement = $(``);
+ this.parentElement.append(this.counterElement);
+ } else {
+ this.counterElement = this.parentElement.find(".char-counter");
+ }
+
+ this.updateCounter();
+ this.textareaElement.on("input", () => this.updateCounter());
+ }
+
+ private updateCounter(): void {
+ const maxLength = this.maxLength;
+ const currentLength = (this.textareaElement.val() as string).length;
+ const remaining = maxLength - currentLength;
+ this.counterElement.text(`${currentLength}/${maxLength}`);
+
+ const remainingPercentage = (remaining / this.maxLength) * 100;
+
+ this.counterElement.removeClass("warning");
+ this.counterElement.removeClass("error");
+
+ if (remainingPercentage === 0) {
+ this.counterElement.addClass("error");
+ } else if (remainingPercentage < 10) {
+ this.counterElement.addClass("warning");
+ }
+ }
+
+ public setMaxLength(maxLength: number): void {
+ this.maxLength = maxLength;
+ this.updateCounter();
+ }
+}
diff --git a/frontend/src/ts/modals/edit-profile.ts b/frontend/src/ts/modals/edit-profile.ts
index dfa73adf0..c50af841c 100644
--- a/frontend/src/ts/modals/edit-profile.ts
+++ b/frontend/src/ts/modals/edit-profile.ts
@@ -6,6 +6,7 @@ import * as Notifications from "../elements/notifications";
import * as ConnectionState from "../states/connection";
import AnimatedModal from "../utils/animated-modal";
import * as Profile from "../elements/profile";
+import { CharacterCounter } from "../elements/character-counter";
export function show(): void {
if (!ConnectionState.get()) {
@@ -18,6 +19,7 @@ export function show(): void {
void modal.show({
beforeAnimation: async () => {
hydrateInputs();
+ initializeCharacterCounters();
},
});
}
@@ -32,8 +34,10 @@ function hide(): void {
});
}
-const bioInput = $("#editProfileModal .bio");
-const keyboardInput = $("#editProfileModal .keyboard");
+const bioInput: JQuery = $("#editProfileModal .bio");
+const keyboardInput: JQuery = $(
+ "#editProfileModal .keyboard"
+);
const twitterInput = $("#editProfileModal .twitter");
const githubInput = $("#editProfileModal .github");
const websiteInput = $("#editProfileModal .website");
@@ -87,6 +91,11 @@ function hydrateInputs(): void {
});
}
+function initializeCharacterCounters(): void {
+ new CharacterCounter(bioInput, 250);
+ new CharacterCounter(keyboardInput, 75);
+}
+
function buildUpdatesFromInputs(): SharedTypes.UserProfileDetails {
const bio = (bioInput.val() ?? "") as string;
const keyboard = (keyboardInput.val() ?? "") as string;
diff --git a/frontend/src/ts/modals/quote-report.ts b/frontend/src/ts/modals/quote-report.ts
index d8a5f24d5..7bb72ae37 100644
--- a/frontend/src/ts/modals/quote-report.ts
+++ b/frontend/src/ts/modals/quote-report.ts
@@ -7,6 +7,7 @@ import * as CaptchaController from "../controllers/captcha-controller";
import { removeLanguageSize } from "../utils/strings";
import SlimSelect from "slim-select";
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
+import { CharacterCounter } from "../elements/character-counter";
type State = {
quoteToReport?: MonkeyTypes.Quote;
@@ -42,7 +43,6 @@ export async function show(
$("#quoteReportModal .quote").text(state.quoteToReport?.text as string);
$("#quoteReportModal .reason").val("Grammatical error");
$("#quoteReportModal .comment").val("");
- $("#quoteReportModal .characterCount").text("-");
state.reasonSelect = new SlimSelect({
select: "#quoteReportModal .reason",
@@ -50,6 +50,11 @@ export async function show(
showSearch: false,
},
});
+
+ new CharacterCounter(
+ $("#quoteReportModal .comment") as JQuery,
+ 250
+ );
},
});
}
@@ -115,15 +120,6 @@ async function submitReport(): Promise {
}
async function setup(modalEl: HTMLElement): Promise {
- modalEl.querySelector(".comment")?.addEventListener("input", () => {
- const len = ($("#quoteReportModal .comment").val() as string).length;
- $("#quoteReportModal .characterCount").text(len);
- if (len > 250) {
- $("#quoteReportModal .characterCount").addClass("red");
- } else {
- $("#quoteReportModal .characterCount").removeClass("red");
- }
- });
modalEl.querySelector("button")?.addEventListener("click", async () => {
await submitReport();
});
diff --git a/frontend/src/ts/modals/quote-submit.ts b/frontend/src/ts/modals/quote-submit.ts
index b4f6e485f..370c4eb28 100644
--- a/frontend/src/ts/modals/quote-submit.ts
+++ b/frontend/src/ts/modals/quote-submit.ts
@@ -7,6 +7,7 @@ import * as JSONData from "../utils/json-data";
import Config from "../config";
import SlimSelect from "slim-select";
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
+import { CharacterCounter } from "../elements/character-counter";
let dropdownReady = false;
async function initDropdown(): Promise {
@@ -44,8 +45,6 @@ async function submitQuote(): Promise {
Notifications.add("Quote submitted.", 1);
$("#quoteSubmitModal .newQuoteText").val("");
$("#quoteSubmitModal .newQuoteSource").val("");
- $("#quoteSubmitModal .characterCount").removeClass("red");
- $("#quoteSubmitModal .characterCount").text("-");
CaptchaController.reset("submitQuote");
}
@@ -70,6 +69,11 @@ export async function show(showOptions: ShowOptions): Promise {
);
$("#quoteSubmitModal .newQuoteLanguage").trigger("change");
$("#quoteSubmitModal input").val("");
+
+ new CharacterCounter(
+ $("#quoteSubmitModal .newQuoteText") as JQuery,
+ 250
+ );
},
});
}
@@ -86,15 +90,6 @@ function hide(clearModalChain: boolean): void {
}
async function setup(modalEl: HTMLElement): Promise {
- modalEl.querySelector("textarea")?.addEventListener("input", (e) => {
- const len = (e.target as HTMLTextAreaElement).value.length;
- $("#quoteSubmitModal .characterCount").text(len);
- if (len < 60) {
- $("#quoteSubmitModal .characterCount").addClass("red");
- } else {
- $("#quoteSubmitModal .characterCount").removeClass("red");
- }
- });
modalEl.querySelector("button")?.addEventListener("click", () => {
void submitQuote();
hide(true);
diff --git a/frontend/src/ts/modals/user-report.ts b/frontend/src/ts/modals/user-report.ts
index 864d9b2eb..400fc6f54 100644
--- a/frontend/src/ts/modals/user-report.ts
+++ b/frontend/src/ts/modals/user-report.ts
@@ -5,6 +5,7 @@ import * as CaptchaController from "../controllers/captcha-controller";
import SlimSelect from "slim-select";
import AnimatedModal from "../utils/animated-modal";
import { isAuthenticated } from "../firebase";
+import { CharacterCounter } from "../elements/character-counter";
type State = {
userUid?: string;
@@ -46,8 +47,6 @@ export async function show(options: ShowOptions): Promise {
(modalEl.querySelector(".reason") as HTMLSelectElement).value =
"Inappropriate name";
(modalEl.querySelector(".comment") as HTMLTextAreaElement).value = "";
- (modalEl.querySelector(".characterCount") as HTMLElement).textContent =
- "-";
select = new SlimSelect({
select: modalEl.querySelector(".reason") as HTMLElement,
@@ -58,6 +57,11 @@ export async function show(options: ShowOptions): Promise {
});
},
});
+
+ new CharacterCounter(
+ $("#userReportModal .comment") as JQuery,
+ 250
+ );
}
async function hide(): Promise {
@@ -128,19 +132,5 @@ const modal = new AnimatedModal({
modalEl.querySelector("button")?.addEventListener("click", () => {
void submitReport();
});
- modalEl.querySelector(".comment")?.addEventListener("input", (e) => {
- setTimeout(() => {
- const len = (e.target as HTMLTextAreaElement).value.length;
- const characterCount = modalEl.querySelector(
- ".characterCount"
- ) as HTMLElement;
- characterCount.textContent = len.toString();
- if (len > 250) {
- characterCount.classList.add("red");
- } else {
- characterCount.classList.remove("red");
- }
- }, 1);
- });
},
});