mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-10-17 19:15:59 +08:00
refactor: modal system (#5162)
* add animated modal class * convert one popup to new system * also grabbing backdrop classes * rename * padd modal as parameter * rename to wrapper * unnecessary constant * rename parameter remove first hashtag if present * combine types * rename popup to modal * reduce options object nesting
This commit is contained in:
parent
9cbca109ea
commit
1d7fde25a3
5 changed files with 294 additions and 59 deletions
|
@ -363,12 +363,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settingsImportWrapper" class="popupWrapper hidden">
|
||||
<div id="settingsImport" action="">
|
||||
<dialog id="settingsImportPopup" class="modalWrapper hidden">
|
||||
<form class="modal" data-mode="">
|
||||
<input type="text" title="import settings" />
|
||||
<div class="button">import settings</div>
|
||||
</div>
|
||||
</div>
|
||||
<button>import settings</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="customThemeShareWrapper" class="popupWrapper hidden">
|
||||
<div id="customThemeShare" action="">
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
.popupWrapper {
|
||||
.popupWrapper,
|
||||
.backdrop,
|
||||
.modalWrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
|
@ -11,9 +13,11 @@
|
|||
z-index: 1000;
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
padding: 2rem;
|
||||
border: none;
|
||||
grid-template-columns: 100%;
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
@ -22,6 +26,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--roundness);
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#customTextPopupWrapper {
|
||||
#customTextPopup {
|
||||
background: var(--bg-color);
|
||||
|
|
222
frontend/src/ts/popups/animated-modal.ts
Normal file
222
frontend/src/ts/popups/animated-modal.ts
Normal file
|
@ -0,0 +1,222 @@
|
|||
import { isPopupVisible } from "../utils/misc";
|
||||
import * as Skeleton from "./skeleton";
|
||||
|
||||
type ShowHideOptions = {
|
||||
animationMode?: "none" | "both" | "modalOnly";
|
||||
animationDurationMs?: number;
|
||||
customAnimation?: {
|
||||
wrapper?: {
|
||||
from: Record<string, string>;
|
||||
to: Record<string, string>;
|
||||
easing?: string;
|
||||
};
|
||||
modal?: {
|
||||
from: Record<string, string>;
|
||||
to: Record<string, string>;
|
||||
easing?: string;
|
||||
};
|
||||
};
|
||||
beforeAnimation?: (modal: HTMLElement) => Promise<void>;
|
||||
afterAnimation?: (modal: HTMLElement) => Promise<void>;
|
||||
};
|
||||
|
||||
export class AnimatedModal {
|
||||
private wrapperEl: HTMLDialogElement;
|
||||
private modalEl: HTMLElement;
|
||||
private wrapperId: string;
|
||||
private open = false;
|
||||
|
||||
constructor(wrapperId: string) {
|
||||
if (wrapperId.startsWith("#")) {
|
||||
wrapperId = wrapperId.slice(1);
|
||||
}
|
||||
|
||||
const dialogElement = document.getElementById(wrapperId);
|
||||
const modalElement = document.querySelector(
|
||||
`#${wrapperId} > .modal`
|
||||
) as HTMLElement;
|
||||
|
||||
if (dialogElement === null) {
|
||||
throw new Error(`Dialog element with id ${wrapperId} not found`);
|
||||
}
|
||||
|
||||
if (!(dialogElement instanceof HTMLDialogElement)) {
|
||||
throw new Error("Animated dialog must be an HTMLDialogElement");
|
||||
}
|
||||
|
||||
if (dialogElement === null) {
|
||||
throw new Error(`Dialog element with id ${wrapperId} not found`);
|
||||
}
|
||||
|
||||
if (modalElement === null) {
|
||||
throw new Error(
|
||||
`Div element inside #${wrapperId} with class 'modal' not found`
|
||||
);
|
||||
}
|
||||
|
||||
this.wrapperId = wrapperId;
|
||||
this.wrapperEl = dialogElement;
|
||||
this.modalEl = modalElement;
|
||||
|
||||
this.wrapperEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && isPopupVisible(this.wrapperId)) {
|
||||
void this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
this.wrapperEl.addEventListener("mousedown", (e) => {
|
||||
if (e.target === this.wrapperEl) {
|
||||
void this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
Skeleton.save(this.wrapperId);
|
||||
}
|
||||
|
||||
getWrapper(): HTMLDialogElement {
|
||||
return this.wrapperEl;
|
||||
}
|
||||
|
||||
getModal(): HTMLElement {
|
||||
return this.modalEl;
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.open;
|
||||
}
|
||||
|
||||
async show(options?: ShowHideOptions): Promise<void> {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve) => {
|
||||
Skeleton.append(this.wrapperId);
|
||||
if (isPopupVisible(this.wrapperId)) return resolve();
|
||||
|
||||
this.open = true;
|
||||
this.wrapperEl.showModal();
|
||||
|
||||
await options?.beforeAnimation?.(this.modalEl);
|
||||
|
||||
const animationMode = options?.animationMode ?? "both";
|
||||
const animationDuration = options?.animationDurationMs ?? 125;
|
||||
|
||||
$(this.modalEl).stop(true, false);
|
||||
$(this.wrapperEl).stop(true, false);
|
||||
|
||||
if (animationMode === "both" || animationMode === "none") {
|
||||
if (options?.customAnimation?.modal?.from) {
|
||||
$(this.modalEl).css(options.customAnimation.modal.from);
|
||||
$(this.modalEl).animate(
|
||||
options.customAnimation.modal.to,
|
||||
animationMode === "none" ? 0 : animationDuration,
|
||||
options.customAnimation.modal.easing ?? "swing"
|
||||
);
|
||||
} else {
|
||||
$(this.modalEl).css("opacity", "1");
|
||||
}
|
||||
|
||||
if (options?.customAnimation?.wrapper?.from) {
|
||||
$(this.wrapperEl).css(options.customAnimation.wrapper.from);
|
||||
}
|
||||
$(this.wrapperEl)
|
||||
.removeClass("hidden")
|
||||
.css("opacity", "0")
|
||||
.animate(
|
||||
options?.customAnimation?.wrapper?.to ?? { opacity: 1 },
|
||||
animationMode === "none" ? 0 : animationDuration,
|
||||
options?.customAnimation?.wrapper?.easing ?? "swing",
|
||||
async () => {
|
||||
this.wrapperEl.focus();
|
||||
await options?.afterAnimation?.(this.modalEl);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
} else if (animationMode === "modalOnly") {
|
||||
$(this.wrapperEl).removeClass("hidden").css("opacity", "1");
|
||||
|
||||
if (options?.customAnimation?.modal?.from) {
|
||||
$(this.modalEl).css(options.customAnimation.modal.from);
|
||||
} else {
|
||||
$(this.modalEl).css("opacity", "0");
|
||||
}
|
||||
$(this.modalEl).animate(
|
||||
options?.customAnimation?.modal?.to ?? { opacity: 1 },
|
||||
animationDuration,
|
||||
options?.customAnimation?.modal?.easing ?? "swing",
|
||||
async () => {
|
||||
this.wrapperEl.focus();
|
||||
await options?.afterAnimation?.(this.modalEl);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async hide(options?: ShowHideOptions): Promise<void> {
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
return new Promise(async (resolve) => {
|
||||
if (!isPopupVisible(this.wrapperId)) return resolve();
|
||||
|
||||
await options?.beforeAnimation?.(this.modalEl);
|
||||
|
||||
const animationMode = options?.animationMode ?? "both";
|
||||
const animationDuration = options?.animationDurationMs ?? 125;
|
||||
|
||||
$(this.modalEl).stop(true, false);
|
||||
$(this.wrapperEl).stop(true, false);
|
||||
|
||||
if (animationMode === "both" || animationMode === "none") {
|
||||
if (options?.customAnimation?.modal?.from) {
|
||||
$(this.modalEl).css(options.customAnimation.modal.from);
|
||||
$(this.modalEl).animate(
|
||||
options.customAnimation.modal.to,
|
||||
animationMode === "none" ? 0 : animationDuration,
|
||||
options.customAnimation.modal.easing ?? "swing"
|
||||
);
|
||||
} else {
|
||||
$(this.modalEl).css("opacity", "0");
|
||||
}
|
||||
|
||||
if (options?.customAnimation?.wrapper?.from) {
|
||||
$(this.wrapperEl).css(options.customAnimation.wrapper.from);
|
||||
}
|
||||
$(this.wrapperEl)
|
||||
.css("opacity", "1")
|
||||
.animate(
|
||||
options?.customAnimation?.wrapper?.to ?? { opacity: 0 },
|
||||
animationMode === "none" ? 0 : animationDuration,
|
||||
options?.customAnimation?.wrapper?.easing ?? "swing",
|
||||
async () => {
|
||||
this.wrapperEl.close();
|
||||
this.wrapperEl.classList.add("hidden");
|
||||
Skeleton.remove(this.wrapperId);
|
||||
this.open = false;
|
||||
await options?.afterAnimation?.(this.modalEl);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
} else if (animationMode === "modalOnly") {
|
||||
$(this.wrapperEl).removeClass("hidden").css("opacity", "1");
|
||||
|
||||
if (options?.customAnimation?.modal?.from) {
|
||||
$(this.modalEl).css(options.customAnimation.modal.from);
|
||||
} else {
|
||||
$(this.modalEl).css("opacity", "1");
|
||||
}
|
||||
$(this.modalEl).animate(
|
||||
options?.customAnimation?.modal?.to ?? { opacity: 0 },
|
||||
animationDuration,
|
||||
options?.customAnimation?.modal?.easing ?? "swing",
|
||||
async () => {
|
||||
this.wrapperEl.close();
|
||||
$(this.wrapperEl).addClass("hidden").css("opacity", "0");
|
||||
Skeleton.remove(this.wrapperId);
|
||||
this.open = false;
|
||||
await options?.afterAnimation?.(this.modalEl);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,65 +1,61 @@
|
|||
import * as UpdateConfig from "../config";
|
||||
import * as Notifications from "../elements/notifications";
|
||||
import { isPopupVisible } from "../utils/misc";
|
||||
import * as Skeleton from "./skeleton";
|
||||
import { AnimatedModal } from "./animated-modal";
|
||||
|
||||
const wrapperId = "settingsImportWrapper";
|
||||
type State = {
|
||||
mode: "import" | "export";
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function show(mode: string, config?: string): void {
|
||||
Skeleton.append(wrapperId);
|
||||
const state: State = {
|
||||
mode: "import",
|
||||
value: "",
|
||||
};
|
||||
|
||||
if (!isPopupVisible(wrapperId)) {
|
||||
$("#settingsImportWrapper").attr("mode", mode);
|
||||
export function show(mode: "import" | "export", config?: string): void {
|
||||
state.mode = mode;
|
||||
state.value = config ?? "";
|
||||
|
||||
if (mode === "export") {
|
||||
$("#settingsImportWrapper .button").addClass("hidden");
|
||||
$("#settingsImportWrapper input").val(config ?? "");
|
||||
} else if (mode === "import") {
|
||||
$("#settingsImportWrapper .button").removeClass("hidden");
|
||||
}
|
||||
|
||||
$("#settingsImportWrapper")
|
||||
.stop(true, true)
|
||||
.css("opacity", 0)
|
||||
.removeClass("hidden")
|
||||
.animate({ opacity: 1 }, 125, () => {
|
||||
$("#settingsImportWrapper input").trigger("focus");
|
||||
$("#settingsImportWrapper input").trigger("select");
|
||||
$("#settingsImportWrapper input").trigger("focus");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function hide(): Promise<void> {
|
||||
if (isPopupVisible(wrapperId)) {
|
||||
if ($("#settingsImportWrapper input").val() !== "") {
|
||||
try {
|
||||
await UpdateConfig.apply(
|
||||
JSON.parse($("#settingsImportWrapper input").val() as string)
|
||||
);
|
||||
} catch (e) {
|
||||
Notifications.add("Failed to import settings: " + e, -1);
|
||||
void modal.show({
|
||||
beforeAnimation: async (modal) => {
|
||||
(modal.querySelector("input") as HTMLInputElement).value = state.value;
|
||||
if (state.mode === "export") {
|
||||
modal.querySelector("button")?.classList.add("hidden");
|
||||
modal.querySelector("input")?.setAttribute("readonly", "true");
|
||||
} else if (state.mode === "import") {
|
||||
modal.querySelector("button")?.classList.remove("hidden");
|
||||
modal.querySelector("input")?.removeAttribute("readonly");
|
||||
}
|
||||
UpdateConfig.saveFullConfigToLocalStorage();
|
||||
}
|
||||
$("#settingsImportWrapper")
|
||||
.stop(true, true)
|
||||
.css("opacity", 1)
|
||||
.animate({ opacity: 0 }, 125, () => {
|
||||
$("#settingsImportWrapper").addClass("hidden");
|
||||
Skeleton.remove(wrapperId);
|
||||
});
|
||||
}
|
||||
},
|
||||
afterAnimation: async (modal) => {
|
||||
const inputEl = modal.querySelector("input");
|
||||
if (state.mode === "import") {
|
||||
inputEl?.focus();
|
||||
} else if (state.mode === "export") {
|
||||
inputEl?.select();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
$("#settingsImportWrapper .button").on("click", () => {
|
||||
void hide();
|
||||
$("#settingsImportPopup input").on("input", (e) => {
|
||||
state.value = (e.target as HTMLInputElement).value;
|
||||
});
|
||||
|
||||
$("#settingsImportWrapper").on("click", (e) => {
|
||||
if ($(e.target).attr("id") === "settingsImportWrapper") {
|
||||
void hide();
|
||||
$("#settingsImportPopup form").on("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (state.mode !== "import") return;
|
||||
if (state.value === "") {
|
||||
void modal.hide();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await UpdateConfig.apply(JSON.parse(state.value));
|
||||
} catch (e) {
|
||||
Notifications.add("Failed to import settings: " + e, -1);
|
||||
}
|
||||
UpdateConfig.saveFullConfigToLocalStorage();
|
||||
void modal.hide();
|
||||
});
|
||||
|
||||
Skeleton.save(wrapperId);
|
||||
const modal = new AnimatedModal("settingsImportPopup");
|
||||
|
|
|
@ -1355,7 +1355,9 @@ export function isPopupVisible(popupId: string): boolean {
|
|||
}
|
||||
|
||||
export function isAnyPopupVisible(): boolean {
|
||||
const popups = document.querySelectorAll("#popups .popupWrapper");
|
||||
const popups = document.querySelectorAll(
|
||||
"#popups .popupWrapper, #popups .backdrop, #popups .modalWrapper"
|
||||
);
|
||||
let popupVisible = false;
|
||||
for (const popup of popups) {
|
||||
if (isPopupVisible(popup.id)) {
|
||||
|
|
Loading…
Add table
Reference in a new issue