Forráskód Böngészése

feat: Dialog class

Sv443 8 hónapja
szülő
commit
7335c59
3 módosított fájl, 567 hozzáadás és 0 törlés
  1. 5 0
      .changeset/smart-squids-lick.md
  2. 560 0
      lib/Dialog.ts
  3. 2 0
      lib/index.ts

+ 5 - 0
.changeset/smart-squids-lick.md

@@ -0,0 +1,5 @@
+---
+"@sv443-network/userutils": minor
+---
+
+Added Dialog class for easily creating customizable modal dialogs

+ 560 - 0
lib/Dialog.ts

@@ -0,0 +1,560 @@
+// hoist the class declaration because either rollup or babel is being a hoe
+import { addGlobalStyle } from "./dom.js";
+import { NanoEmitter } from "./NanoEmitter.js";
+
+export const defaultDialogCss = `\
+.uu-no-select {
+  user-select: none;
+}
+
+.uu-dialog-bg {
+  --uu-dialog-bg: #333333;
+  --uu-dialog-bg-highlight: #252525;
+  --uu-scroll-indicator-bg: rgba(10, 10, 10, 0.7);
+  --uu-dialog-separator-color: #797979;
+  --uu-dialog-border-radius: 10px;
+}
+
+.uu-dialog-bg {
+  display: block;
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  z-index: 5;
+  background-color: rgba(0, 0, 0, 0.6);
+}
+
+.uu-dialog {
+  --uu-calc-dialog-height: calc(min(100vh - 40px, var(--uu-dialog-height-max)));
+  position: absolute;
+  display: flex;
+  flex-direction: column;
+  width: calc(min(100% - 60px, var(--uu-dialog-width-max)));
+  border-radius: var(--uu-dialog-border-radius);
+  height: auto;
+  max-height: var(--uu-calc-dialog-height);
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 6;
+  color: #fff;
+  background-color: var(--uu-dialog-bg);
+}
+
+.uu-dialog.align-top {
+  top: 0;
+  transform: translate(-50%, 40px);
+}
+
+.uu-dialog.align-bottom {
+  top: 100%;
+  transform: translate(-50%, -100%);
+}
+
+.uu-dialog-body {
+  font-size: 1.5rem;
+  padding: 20px;
+}
+
+.uu-dialog-body.small {
+  padding: 15px;
+}
+
+#uu-dialog-opts {
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  padding: 30px 0px;
+  overflow-y: auto;
+}
+
+.uu-dialog-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 6px;
+  padding: 15px 20px 15px 20px;
+  background-color: var(--uu-dialog-bg);
+  border: 2px solid var(--uu-dialog-separator-color);
+  border-style: none none solid none !important;
+  border-radius: var(--uu-dialog-border-radius) var(--uu-dialog-border-radius) 0px 0px;
+}
+
+.uu-dialog-header.small {
+  padding: 10px 15px;
+  border-style: none none solid none !important;
+}
+
+.uu-dialog-header-pad {
+  content: " ";
+  min-height: 32px;
+}
+
+.uu-dialog-header-pad.small {
+  min-height: 24px;
+}
+
+.uu-dialog-titlecont {
+  display: flex;
+  align-items: center;
+}
+
+.uu-dialog-titlecont-no-title {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+}
+
+.uu-dialog-title {
+  position: relative;
+  display: inline-block;
+  font-size: 22px;
+}
+
+.uu-dialog-close {
+  cursor: pointer;
+}
+
+.uu-dialog-header-img,
+.uu-dialog-close
+{
+  width: 32px;
+  height: 32px;
+}
+
+.uu-dialog-header-img.small,
+.uu-dialog-close.small
+{
+  width: 24px;
+  height: 24px;
+}
+
+.uu-dialog-footer {
+  font-size: 17px;
+  text-decoration: underline;
+}
+
+.uu-dialog-footer.hidden {
+  display: none;
+}
+
+.uu-dialog-footer-cont {
+  margin-top: 6px;
+  padding: 15px 20px;
+  background: var(--uu-dialog-bg);
+  background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, var(--uu-dialog-bg) 30%, var(--uu-dialog-bg) 100%);
+  border: 2px solid var(--uu-dialog-separator-color);
+  border-style: solid none none none !important;
+  border-radius: 0px 0px var(--uu-dialog-border-radius) var(--uu-dialog-border-radius);
+}
+
+.uu-dialog-footer-buttons-cont button:not(:last-of-type) {
+  margin-right: 15px;
+}`;
+
+/** ID of the last opened (top-most) dialog */
+export let currentDialogId: string | null = null;
+/** IDs of all currently open dialogs, top-most first */
+export const openDialogs: string[] = [];
+
+export const defaultStrings = {
+  closeDialogTooltip: "Click to close the dialog",
+};
+
+/** Options passed to the Dialog constructor */
+export interface DialogOptions {
+  /** ID that gets added to child element IDs - has to be unique and conform to HTML ID naming rules! */
+  id: string;
+  /** Target and max width of the dialog in pixels */
+  width: number;
+  /** Target and max height of the dialog in pixels */
+  height: number;
+  /** Whether the dialog should close when the background is clicked - defaults to true */
+  closeOnBgClick?: boolean;
+  /** Whether the dialog should close when the escape key is pressed - defaults to true */
+  closeOnEscPress?: boolean;
+  /** Whether the dialog should be destroyed when it's closed - defaults to false */
+  destroyOnClose?: boolean;
+  /** Whether the dialog should be unmounted when it's closed - defaults to true - superseded by destroyOnClose */
+  unmountOnClose?: boolean;
+  /** Whether all listeners should be removed when the dialog is destroyed - defaults to true */
+  removeListenersOnDestroy?: boolean;
+  /** Whether the dialog should have a smaller overall appearance - defaults to false */
+  small?: boolean;
+  /** Where to align or anchor the dialog vertically - defaults to "center" */
+  verticalAlign?: "top" | "center" | "bottom";
+  /** Strings used in the dialog (used for translations) - defaults to the default English strings */
+  strings?: Partial<typeof defaultStrings>;
+  /** CSS to apply to the dialog - defaults to the {@linkcode defaultDialogCss} */
+  dialogCss?: string;
+  /** Called to render the body of the dialog */
+  renderBody: () => HTMLElement | Promise<HTMLElement>;
+  /** Called to render the header of the dialog - leave undefined for a blank header */
+  renderHeader?: () => HTMLElement | Promise<HTMLElement>;
+  /** Called to render the footer of the dialog - leave undefined for no footer */
+  renderFooter?: () => HTMLElement | Promise<HTMLElement>;
+  /** Called to render the close button of the dialog - leave undefined for no close button */
+  renderCloseBtn?: () => HTMLElement | Promise<HTMLElement>;
+}
+
+/** Creates and manages a modal dialog element */
+export class Dialog extends NanoEmitter<{
+  /** Emitted just **after** the dialog is closed */
+  close: () => void;
+  /** Emitted just **after** the dialog is opened */
+  open: () => void;
+  /** Emitted just **after** the dialog contents are rendered */
+  render: () => void;
+  /** Emitted just **after** the dialog contents are cleared */
+  clear: () => void;
+  /** Emitted just **after** the dialog is destroyed and **before** all listeners are removed */
+  destroy: () => void;
+}> {
+  /** Options passed to the dialog in the constructor */
+  public readonly options;
+  /** ID that gets added to child element IDs - has to be unique and conform to HTML ID naming rules! */
+  public readonly id;
+  /** Strings used in the dialog (used for translations) */
+  public strings;
+
+  protected dialogOpen = false;
+  protected dialogMounted = false;
+
+  constructor(options: DialogOptions) {
+    super();
+
+    const { strings, ...opts } = options;
+
+    this.strings = {
+      ...defaultStrings,
+      ...(strings ?? {}),
+    };
+
+    this.options = {
+      closeOnBgClick: true,
+      closeOnEscPress: true,
+      destroyOnClose: false,
+      unmountOnClose: true,
+      removeListenersOnDestroy: true,
+      smallHeader: false,
+      verticalAlign: "center",
+      dialogCss: defaultDialogCss,
+      ...opts,
+    };
+    this.id = opts.id;
+  }
+
+  //#region public
+
+  /** Call after DOMContentLoaded to pre-render the dialog and invisibly mount it in the DOM */
+  public async mount() {
+    if(this.dialogMounted)
+      return;
+    this.dialogMounted = true;
+
+    if(!document.querySelector("style.uu-dialog-css"))
+      addGlobalStyle(this.options.dialogCss).classList.add("uu-dialog-css");
+
+    const bgElem = document.createElement("div");
+    bgElem.id = `uu-${this.id}-dialog-bg`;
+    bgElem.classList.add("uu-dialog-bg");
+    if(this.options.closeOnBgClick)
+      bgElem.ariaLabel = bgElem.title = this.getString("closeDialogTooltip");
+
+    bgElem.style.setProperty("--uu-dialog-width-max", `${this.options.width}px`);
+    bgElem.style.setProperty("--uu-dialog-height-max", `${this.options.height}px`);
+
+    bgElem.style.visibility = "hidden";
+    bgElem.style.display = "none";
+    bgElem.inert = true;
+
+    bgElem.appendChild(await this.getDialogContent());
+    document.body.appendChild(bgElem);
+
+    this.attachListeners(bgElem);
+
+    this.events.emit("render");
+    return bgElem;
+  }
+
+  /** Closes the dialog and clears all its contents (unmounts elements from the DOM) in preparation for a new rendering call */
+  public unmount() {
+    this.close();
+
+    this.dialogMounted = false;
+
+    const clearSelectors = [
+      `#uu-${this.id}-dialog-bg`,
+      `#uu-style-dialog-${this.id}`,
+    ];
+
+    for(const sel of clearSelectors)
+      document.querySelector(sel)?.remove();
+
+    this.events.emit("clear");
+  }
+
+  /** Clears the DOM of the dialog and then renders it again */
+  public async remount() {
+    this.unmount();
+    await this.mount();
+  }
+
+  /**
+   * Opens the dialog - also mounts it if it hasn't been mounted yet  
+   * Prevents default action and immediate propagation of the passed event
+   */
+  public async open(e?: MouseEvent | KeyboardEvent) {
+    e?.preventDefault();
+    e?.stopImmediatePropagation();
+
+    if(this.isOpen())
+      return;
+    this.dialogOpen = true;
+
+    if(openDialogs.includes(this.id))
+      throw new Error(`A dialog with the same ID of '${this.id}' already exists and is open!`);
+
+    if(!this.isMounted())
+      await this.mount();
+
+    const dialogBg = document.querySelector<HTMLElement>(`#uu-${this.id}-dialog-bg`);
+
+    if(!dialogBg)
+      return console.warn(`Couldn't find background element for dialog with ID '${this.id}'`);
+
+    dialogBg.style.visibility = "visible";
+    dialogBg.style.display = "block";
+    dialogBg.inert = false;
+
+    currentDialogId = this.id;
+    openDialogs.unshift(this.id);
+
+    // make sure all other dialogs are inert
+    for(const dialogId of openDialogs)
+      if(dialogId !== this.id)
+        document.querySelector(`#uu-${dialogId}-dialog-bg`)?.setAttribute("inert", "true");
+
+    // make sure body is inert and scroll is locked
+    document.body.classList.remove("uu-no-select");
+    document.body.setAttribute("inert", "true");
+
+    this.events.emit("open");
+
+    return dialogBg;
+  }
+
+  /** Closes the dialog - prevents default action and immediate propagation of the passed event */
+  public close(e?: MouseEvent | KeyboardEvent) {
+    e?.preventDefault();
+    e?.stopImmediatePropagation();
+
+    if(!this.isOpen())
+      return;
+    this.dialogOpen = false;
+
+    const dialogBg = document.querySelector<HTMLElement>(`#uu-${this.id}-dialog-bg`);
+
+    if(!dialogBg)
+      return console.warn(`Couldn't find background element for dialog with ID '${this.id}'`);
+
+    dialogBg.style.visibility = "hidden";
+    dialogBg.style.display = "none";
+    dialogBg.inert = true;
+
+    openDialogs.splice(openDialogs.indexOf(this.id), 1);
+    currentDialogId = openDialogs[0] ?? null;
+
+    // make sure the new top-most dialog is not inert
+    if(currentDialogId)
+      document.querySelector(`#uu-${currentDialogId}-dialog-bg`)?.removeAttribute("inert");
+
+    // remove the scroll lock and inert attribute on the body if no dialogs are open
+    if(openDialogs.length === 0) {
+      document.body.classList.add("uu-no-select");
+      document.body.removeAttribute("inert");
+    }
+
+    this.events.emit("close");
+
+    if(this.options.destroyOnClose)
+      this.destroy();
+    // don't destroy *and* unmount at the same time
+    else if(this.options.unmountOnClose)
+      this.unmount();
+  }
+
+  /** Returns true if the dialog is currently open */
+  public isOpen() {
+    return this.dialogOpen;
+  }
+
+  /** Returns true if the dialog is currently mounted */
+  public isMounted() {
+    return this.dialogMounted;
+  }
+
+  /** Clears the DOM of the dialog and removes all event listeners */
+  public destroy() {
+    this.unmount();
+    this.events.emit("destroy");
+    this.options.removeListenersOnDestroy && this.unsubscribeAll();
+  }
+
+  //#region static
+
+  /** Returns the ID of the top-most dialog (the dialog that has been opened last) */
+  public static getCurrentDialogId() {
+    return currentDialogId;
+  }
+
+  /** Returns the IDs of all currently open dialogs, top-most first */
+  public static getOpenDialogs() {
+    return openDialogs;
+  }
+
+  //#region protected
+
+  protected getString(key: keyof typeof defaultStrings) {
+    return this.strings[key] ?? defaultStrings[key];
+  }
+
+  /** Called once to attach all generic event listeners */
+  protected attachListeners(bgElem: HTMLElement) {
+    if(this.options.closeOnBgClick) {
+      bgElem.addEventListener("click", (e) => {
+        if(this.isOpen() && (e.target as HTMLElement)?.id === `uu-${this.id}-dialog-bg`)
+          this.close(e);
+      });
+    }
+
+    if(this.options.closeOnEscPress) {
+      document.body.addEventListener("keydown", (e) => {
+        if(e.key === "Escape" && this.isOpen() && Dialog.getCurrentDialogId() === this.id)
+          this.close(e);
+      });
+    }
+  }
+
+  //#region protected
+
+  /**
+   * Adds generic, accessible interaction listeners to the passed element.  
+   * All listeners have the default behavior prevented and stop propagation (for keyboard events only as long as the captured key is valid).
+   * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners
+   */
+  protected onInteraction<
+    TElem extends HTMLElement
+  > (
+    elem: TElem,
+    listener: (evt: MouseEvent | KeyboardEvent) => void,
+    listenerOptions?: AddEventListenerOptions & {
+      preventDefault?: boolean;
+      stopPropagation?: boolean;
+    },
+  ) {
+    const { preventDefault = true, stopPropagation = true, ...listenerOpts } = listenerOptions ?? {};
+
+    const interactionKeys = ["Enter", " ", "Space"];
+
+    const proxListener = (e: MouseEvent | KeyboardEvent) => {
+      if(e instanceof KeyboardEvent) {
+        if(interactionKeys.includes(e.key)) {
+          preventDefault && e.preventDefault();
+          stopPropagation && e.stopPropagation();
+        }
+        else return;
+      }
+      else if(e instanceof MouseEvent) {
+        preventDefault && e.preventDefault();
+        stopPropagation && e.stopPropagation();
+      }
+
+      // clean up the other listener that isn't automatically removed if `once` is set
+      listenerOpts?.once && e.type === "keydown" && elem.removeEventListener("click", proxListener, listenerOpts);
+      listenerOpts?.once && e.type === "click" && elem.removeEventListener("keydown", proxListener, listenerOpts);
+      listener(e);
+    };
+    elem.addEventListener("click", proxListener, listenerOpts);
+    elem.addEventListener("keydown", proxListener, listenerOpts);
+  }
+
+  /** Returns the dialog content element and all its children */
+  protected async getDialogContent() {
+    const header = this.options.renderHeader?.();
+    const footer = this.options.renderFooter?.();
+
+    const dialogWrapperEl = document.createElement("div");
+    dialogWrapperEl.id = `uu-${this.id}-dialog`;
+    dialogWrapperEl.classList.add("uu-dialog");
+    dialogWrapperEl.ariaLabel = dialogWrapperEl.title = "";
+    dialogWrapperEl.role = "dialog";
+    dialogWrapperEl.setAttribute("aria-labelledby", `uu-${this.id}-dialog-title`);
+    dialogWrapperEl.setAttribute("aria-describedby", `uu-${this.id}-dialog-body`);
+
+    if(this.options.verticalAlign !== "center")
+      dialogWrapperEl.classList.add(`align-${this.options.verticalAlign}`);
+
+    //#region header
+
+    const headerWrapperEl = document.createElement("div");
+    headerWrapperEl.classList.add("uu-dialog-header");
+    this.options.small && headerWrapperEl.classList.add("small");
+
+    if(header) {
+      const headerTitleWrapperEl = document.createElement("div");
+      headerTitleWrapperEl.id = `uu-${this.id}-dialog-title`;
+      headerTitleWrapperEl.classList.add("uu-dialog-title-wrapper");
+      headerTitleWrapperEl.role = "heading";
+      headerTitleWrapperEl.ariaLevel = "1";
+
+      headerTitleWrapperEl.appendChild(header instanceof Promise ? await header : header);
+      headerWrapperEl.appendChild(headerTitleWrapperEl);
+    }
+    else {
+      // insert element to pad the header height
+      const padEl = document.createElement("div");
+      padEl.classList.add("uu-dialog-header-pad", this.options.small ? "small" : "");
+      headerWrapperEl.appendChild(padEl);
+    }
+
+    if(this.options.renderCloseBtn) {
+      const closeBtnEl = await this.options.renderCloseBtn();
+      closeBtnEl.classList.add("uu-dialog-close");
+      this.options.small && closeBtnEl.classList.add("small");
+      closeBtnEl.tabIndex = 0;
+      if(closeBtnEl.hasAttribute("alt"))
+        closeBtnEl.setAttribute("alt", this.getString("closeDialogTooltip"));
+      closeBtnEl.title = closeBtnEl.ariaLabel = this.getString("closeDialogTooltip");
+      this.onInteraction(closeBtnEl, () => this.close());
+      headerWrapperEl.appendChild(closeBtnEl);
+    }
+
+    dialogWrapperEl.appendChild(headerWrapperEl);
+
+    //#region body
+
+    const dialogBodyElem = document.createElement("div");
+    dialogBodyElem.id = `uu-${this.id}-dialog-body`;
+    dialogBodyElem.classList.add("uu-dialog-body");
+    this.options.small && dialogBodyElem.classList.add("small");
+
+    const body = this.options.renderBody();
+
+    dialogBodyElem.appendChild(body instanceof Promise ? await body : body);
+    dialogWrapperEl.appendChild(dialogBodyElem);
+
+    //#region footer
+
+    if(footer) {
+      const footerWrapper = document.createElement("div");
+      footerWrapper.classList.add("uu-dialog-footer-cont");
+      dialogWrapperEl.appendChild(footerWrapper);
+      footerWrapper.appendChild(footer instanceof Promise ? await footer : footer);
+    }
+
+    return dialogWrapperEl;
+  }
+}

+ 2 - 0
lib/index.ts

@@ -1,9 +1,11 @@
 export * from "./array.js";
 export * from "./DataStore.js";
 export * from "./DataStoreSerializer.js";
+export * from "./Dialog.js";
 export * from "./dom.js";
 export * from "./math.js";
 export * from "./misc.js";
+export * from "./NanoEmitter.js";
 export * from "./SelectorObserver.js";
 export * from "./translation.js";
 export * from "./types.js";