prompt.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import type { Emitter } from "nanoevents";
  2. import type { Stringifiable } from "@sv443-network/userutils";
  3. import { getOS, resourceAsString, setInnerHtml, t } from "../utils/index.js";
  4. import { BytmDialog, type BytmDialogEvents } from "../components/index.js";
  5. import { addSelectorListener } from "../observers.js";
  6. import "./prompt.css";
  7. //#region types
  8. type PromptStringGen = Stringifiable | ((type: PromptType) => Stringifiable | Promise<Stringifiable>);
  9. export type PromptDialogRenderProps = ConfirmRenderProps | AlertRenderProps | PromptRenderProps;
  10. export type PromptType = PromptDialogRenderProps["type"];
  11. type ConfirmRenderProps = BaseRenderProps & {
  12. type: "confirm";
  13. };
  14. type AlertRenderProps = BaseRenderProps & {
  15. type: "alert";
  16. };
  17. type PromptRenderProps = BaseRenderProps & {
  18. type: "prompt";
  19. defaultValue?: string;
  20. };
  21. type BaseRenderProps = {
  22. message: PromptStringGen;
  23. confirmBtnText?: PromptStringGen;
  24. confirmBtnTooltip?: PromptStringGen;
  25. denyBtnText?: PromptStringGen;
  26. denyBtnTooltip?: PromptStringGen;
  27. };
  28. export type PromptDialogResolveVal = boolean | string | null;
  29. export type ShowPromptProps = Partial<PromptDialogRenderProps> & Required<Pick<PromptDialogRenderProps, "message">>;
  30. export type PromptDialogEmitter = Emitter<BytmDialogEvents & {
  31. resolve: (result: PromptDialogResolveVal) => void;
  32. }>;
  33. //#region PromptDialog
  34. let promptDialog: PromptDialog | null = null;
  35. class PromptDialog extends BytmDialog {
  36. constructor(props: PromptDialogRenderProps) {
  37. super({
  38. id: "prompt-dialog",
  39. width: 500,
  40. height: 400,
  41. destroyOnClose: true,
  42. closeBtnEnabled: true,
  43. closeOnBgClick: props.type === "alert",
  44. closeOnEscPress: true,
  45. small: true,
  46. renderHeader: () => this.renderHeader(props),
  47. renderBody: () => this.renderBody(props),
  48. renderFooter: () => this.renderFooter(props),
  49. });
  50. this.on("render", this.focusOnRender);
  51. }
  52. protected emitResolve(val: PromptDialogResolveVal) {
  53. this.events.emit("resolve", val);
  54. }
  55. protected async renderHeader({ type }: PromptDialogRenderProps) {
  56. const headerEl = document.createElement("div");
  57. headerEl.id = "bytm-prompt-dialog-header";
  58. const iconSvg = await resourceAsString(type === "alert" ? "icon-alert" : "icon-prompt");
  59. if(iconSvg)
  60. setInnerHtml(headerEl, iconSvg);
  61. return headerEl;
  62. }
  63. protected async renderBody({ type, message, ...rest }: PromptDialogRenderProps) {
  64. const contElem = document.createElement("div");
  65. contElem.classList.add(`bytm-prompt-type-${type}`);
  66. const upperContElem = document.createElement("div");
  67. upperContElem.id = "bytm-prompt-dialog-upper-cont";
  68. contElem.appendChild(upperContElem);
  69. const messageElem = document.createElement("p");
  70. messageElem.id = "bytm-prompt-dialog-message";
  71. messageElem.role = "alert";
  72. messageElem.ariaLive = "polite";
  73. messageElem.tabIndex = 0;
  74. messageElem.textContent = String(message);
  75. upperContElem.appendChild(messageElem);
  76. if(type === "prompt") {
  77. const inputElem = document.createElement("input");
  78. inputElem.id = "bytm-prompt-dialog-input";
  79. inputElem.type = "text";
  80. inputElem.autocomplete = "off";
  81. inputElem.spellcheck = false;
  82. inputElem.value = "defaultValue" in rest ? rest.defaultValue ?? "" : "";
  83. const inputEnterListener = (e: KeyboardEvent) => {
  84. if(e.key === "Enter") {
  85. inputElem.removeEventListener("keydown", inputEnterListener);
  86. this.emitResolve(inputElem?.value?.trim() ?? null);
  87. promptDialog?.close();
  88. }
  89. };
  90. inputElem.addEventListener("keydown", inputEnterListener);
  91. promptDialog?.once("close", () => inputElem.removeEventListener("keydown", inputEnterListener));
  92. upperContElem.appendChild(inputElem);
  93. }
  94. return contElem;
  95. }
  96. protected async renderFooter({ type, ...rest }: PromptDialogRenderProps) {
  97. const buttonsWrapper = document.createElement("div");
  98. buttonsWrapper.id = "bytm-prompt-dialog-button-wrapper";
  99. const buttonsCont = document.createElement("div");
  100. buttonsCont.id = "bytm-prompt-dialog-buttons-cont";
  101. let confirmBtn: HTMLButtonElement | undefined;
  102. if(type === "confirm" || type === "prompt") {
  103. confirmBtn = document.createElement("button");
  104. confirmBtn.id = "bytm-prompt-dialog-confirm";
  105. confirmBtn.classList.add("bytm-prompt-dialog-button");
  106. confirmBtn.textContent = await this.consumePromptStringGen(type, rest.confirmBtnText, t("prompt_confirm"));
  107. confirmBtn.ariaLabel = confirmBtn.title = await this.consumePromptStringGen(type, rest.confirmBtnTooltip, t("click_to_confirm_tooltip"));
  108. confirmBtn.tabIndex = 0;
  109. confirmBtn.addEventListener("click", () => {
  110. this.emitResolve(type === "confirm" ? true : (document.querySelector<HTMLInputElement>("#bytm-prompt-dialog-input"))?.value?.trim() ?? null);
  111. promptDialog?.close();
  112. }, { once: true });
  113. }
  114. const closeBtn = document.createElement("button");
  115. closeBtn.id = "bytm-prompt-dialog-close";
  116. closeBtn.classList.add("bytm-prompt-dialog-button");
  117. closeBtn.textContent = await this.consumePromptStringGen(type, rest.denyBtnText, t(type === "alert" ? "prompt_close" : "prompt_cancel"));
  118. closeBtn.ariaLabel = closeBtn.title = await this.consumePromptStringGen(type, rest.denyBtnTooltip, t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip"));
  119. closeBtn.tabIndex = 0;
  120. closeBtn.addEventListener("click", () => {
  121. const resVals: Record<PromptType, boolean | null> = {
  122. alert: true,
  123. confirm: false,
  124. prompt: null,
  125. };
  126. this.emitResolve(resVals[type]);
  127. promptDialog?.close();
  128. }, { once: true });
  129. confirmBtn && getOS() !== "mac" && buttonsCont.appendChild(confirmBtn);
  130. buttonsCont.appendChild(closeBtn);
  131. confirmBtn && getOS() === "mac" && buttonsCont.appendChild(confirmBtn);
  132. buttonsWrapper.appendChild(buttonsCont);
  133. return buttonsWrapper;
  134. }
  135. /** Converts a {@linkcode stringGen} (stringifiable value or sync or async function that returns a stringifiable value) to a string - uses {@linkcode fallback} as a fallback */
  136. protected async consumePromptStringGen(curPromptType: PromptType, stringGen?: PromptStringGen, fallback?: Stringifiable): Promise<string> {
  137. if(typeof stringGen === "function")
  138. return await stringGen(curPromptType);
  139. return String(stringGen ?? fallback);
  140. }
  141. /** Called on render to focus on the confirm or cancel button or text input, depending on prompt type */
  142. protected focusOnRender() {
  143. const inputElem = document.querySelector<HTMLInputElement>("#bytm-prompt-dialog-input");
  144. if(inputElem)
  145. return inputElem.focus();
  146. let captureEnterKey = true;
  147. document.addEventListener("keydown", (e) => {
  148. if(e.key === "Enter" && captureEnterKey) {
  149. const confBtn = document.querySelector<HTMLButtonElement>("#bytm-prompt-dialog-confirm");
  150. const closeBtn = document.querySelector<HTMLButtonElement>("#bytm-prompt-dialog-close");
  151. if(confBtn || closeBtn) {
  152. confBtn?.click() ?? closeBtn?.click();
  153. captureEnterKey = false;
  154. }
  155. }
  156. }, { capture: true, once: true });
  157. }
  158. }
  159. //#region showPrompt fn
  160. /** Shows a `confirm()`-like prompt dialog with the specified message and resolves true if the user confirms it or false if they deny or cancel it */
  161. export function showPrompt(props: ConfirmRenderProps): Promise<boolean>;
  162. /** Shows an `alert()`-like prompt dialog with the specified message and always resolves true once the user dismisses it */
  163. export function showPrompt(props: AlertRenderProps): Promise<true>;
  164. /** Shows a `prompt()`-like dialog with the specified message and default value and resolves the entered value if the user confirms it or null if they cancel it */
  165. export function showPrompt(props: PromptRenderProps): Promise<string | null>;
  166. /** Custom dialog to emulate and enhance the behavior of the native `confirm()`, `alert()`, and `prompt()` functions */
  167. export function showPrompt({ type, ...rest }: PromptDialogRenderProps): Promise<PromptDialogResolveVal> {
  168. return new Promise<PromptDialogResolveVal>((resolve) => {
  169. if(BytmDialog.getOpenDialogs().includes("prompt-dialog"))
  170. promptDialog?.close();
  171. promptDialog = new PromptDialog({ type, ...rest });
  172. promptDialog.once("render" as "_", () => {
  173. addSelectorListener<HTMLButtonElement>("bytmDialogContainer", `#bytm-prompt-dialog-${type === "alert" ? "close" : "confirm"}`, {
  174. listener: (btn) => btn.focus(),
  175. });
  176. });
  177. // make config menu inert while prompt dialog is open
  178. promptDialog.once("open", () => document.querySelector("#bytm-cfg-menu")?.setAttribute("inert", "true"));
  179. promptDialog.once("close", () => document.querySelector("#bytm-cfg-menu")?.removeAttribute("inert"));
  180. let resolveVal: PromptDialogResolveVal | undefined;
  181. const tryResolve = () => resolve(typeof resolveVal !== "undefined" ? resolveVal : false);
  182. let closeUnsub: (() => void) | undefined; // eslint-disable-line prefer-const
  183. const resolveUnsub = promptDialog.on("resolve" as "_", (val: PromptDialogResolveVal) => {
  184. resolveUnsub();
  185. if(resolveVal !== undefined)
  186. return;
  187. resolveVal = val;
  188. tryResolve();
  189. closeUnsub?.();
  190. });
  191. closeUnsub = promptDialog.on("close", () => {
  192. closeUnsub!();
  193. if(resolveVal !== undefined)
  194. return;
  195. resolveVal = type === "alert";
  196. if(type === "prompt")
  197. resolveVal = null;
  198. tryResolve();
  199. resolveUnsub();
  200. });
  201. promptDialog.open();
  202. });
  203. }