Sv443 8 месяцев назад
Родитель
Сommit
ca50132
6 измененных файлов с 289 добавлено и 4 удалено
  1. 5 0
      .changeset/tall-geckos-share.md
  2. 2 0
      README-summary.md
  3. 194 2
      README.md
  4. 70 0
      lib/NanoEmitter.ts
  5. 14 2
      package-lock.json
  6. 4 0
      package.json

+ 5 - 0
.changeset/tall-geckos-share.md

@@ -0,0 +1,5 @@
+---
+"@sv443-network/userutils": minor
+---
+
+Added NanoEmitter class for lightweight event emitter functionality in both OOP and FP

+ 2 - 0
README-summary.md

@@ -42,6 +42,8 @@ or view the documentation of previous major releases:
 - **Misc:**
     - [`DataStore`](https://github.com/Sv443-Network/UserUtils#datastore) - class that manages a hybrid sync & async persistent JSON database, including data migration
     - [`DataStoreSerializer`](https://github.com/Sv443-Network/UserUtils#datastoreserializer) - class for importing & exporting data of multiple DataStore instances, including compression, checksumming and running migrations
+    - [`Dialog`](https://github.com/Sv443-Network/UserUtils#dialog) - class for creating custom modal dialogs with a promise-based API and a generic, default style
+    - [`NanoEmitter`](https://github.com/Sv443-Network/UserUtils#nanoemitter) - tiny event emitter class with a focus on performance and simplicity (based on [nanoevents](https://npmjs.com/package/nanoevents))
     - [`autoPlural()`](https://github.com/Sv443-Network/UserUtils#autoplural) - automatically pluralize a string
     - [`pauseFor()`](https://github.com/Sv443-Network/UserUtils#pausefor) - pause the execution of a function for a given amount of time
     - [`debounce()`](https://github.com/Sv443-Network/UserUtils#debounce) - call a function only once in a series of calls, after or before a given timeout

+ 194 - 2
README.md

@@ -2,7 +2,7 @@
 
 <!-- #MARKER Description -->
 ## UserUtils
-Zero-dependency library with various utilities for userscripts - register listeners for when CSS selectors exist, intercept events, create persistent & synchronous data stores, modify the DOM more easily and more.  
+Lightweight library with various utilities for userscripts - register listeners for when CSS selectors exist, intercept events, create persistent & synchronous data stores, modify the DOM more easily and more.  
   
 Contains builtin TypeScript declarations. Fully web compatible and supports ESM and CJS imports and global declaration.  
 If you like using this library, please consider [supporting the development ❤️](https://github.com/sponsors/Sv443)
@@ -44,6 +44,8 @@ View the documentation of previous major releases:
   - [**Misc:**](#misc)
     - [`DataStore`](#datastore) - class that manages a hybrid sync & async persistent JSON database, including data migration
     - [`DataStoreSerializer`](#datastoreserializer) - class for importing & exporting data of multiple DataStore instances, including compression, checksumming and running migrations
+    - [`Dialog`](#dialog) - class for creating custom modal dialogs with a promise-based API and a generic, default style
+    - [`NanoEmitter`](#nanoemitter) - tiny event emitter class with a focus on performance and simplicity (based on [nanoevents](https://npmjs.com/package/nanoevents))
     - [`autoPlural()`](#autoplural) - automatically pluralize a string
     - [`pauseFor()`](#pausefor) - pause the execution of a function for a given amount of time
     - [`debounce()`](#debounce) - call a function only once in a series of calls, after or before a given timeout
@@ -1246,7 +1248,197 @@ async function importMyDataPls() {
 ```
 </details>
 
-<br><br>
+<br>
+
+### Dialog
+Usage:  
+```ts
+new Dialog(options: DialogOptions)
+```  
+  
+A class that creates a customizable modal dialog with a title (optional), body and footer (optional).  
+There are tons of options for customization, like changing the close behavior, translating strings and more.  
+  
+The options object has the following properties:  
+| Property | Description |
+| :-- | :-- |
+| `id: string` | A unique internal identification string for this instance. If two Dialogs share the same ID, they will overwrite each other. |
+| `width: number` | The target and maximum width of the dialog in pixels. |
+| `height: number` | The target and maximum height of the dialog in pixels. |
+| `renderBody: () => HTMLElement \| Promise<HTMLElement>` | Called to render the body of the dialog. |
+| `renderHeader?: () => HTMLElement \| Promise<HTMLElement>` | (Optional) Called to render the header of the dialog. Leave undefined for a blank header. |
+| `renderFooter?: () => HTMLElement \| Promise<HTMLElement>` | (Optional) Called to render the footer of the dialog. Leave undefined for no footer. |
+| `closeOnBgClick?: boolean` | (Optional) Whether the dialog should close when the background is clicked. Defaults to `true`. |
+| `closeOnEscPress?: boolean` | (Optional) Whether the dialog should close when the escape key is pressed. Defaults to `true`. |
+| `destroyOnClose?: boolean` | (Optional) Whether the dialog should be destroyed when it's closed. Defaults to `false`. |
+| `unmountOnClose?: boolean` | (Optional) Whether the dialog should be unmounted when it's closed. Defaults to `true`. Superseded by `destroyOnClose`. |
+| `removeListenersOnDestroy?: boolean` | (Optional) Whether all listeners should be removed when the dialog is destroyed. Defaults to `true`. |
+| `small?: boolean` | (Optional) Whether the dialog should have a smaller overall appearance. Defaults to `false`. |
+| `verticalAlign?: "top" \| "center" \| "bottom"` | (Optional) Where to align or anchor the dialog vertically. Defaults to `"center"`. |
+| `strings?: Partial<typeof defaultStrings>` | (Optional) Strings used in the dialog (used for translations). Defaults to the default English strings (importable with the name `defaultStrings`). |
+| `dialogCss?: string` | (Optional) CSS to apply to the dialog. Defaults to the default (importable with the name `defaultDialogCss`). |
+  
+Methods:  
+`open(): Promise<void>`  
+Opens the dialog.  
+  
+`close(): void`  
+Closes the dialog.  
+  
+`mount(): Promise<void>`  
+Mounts the dialog to the DOM by calling the render functions provided in the options object.  
+Can be done before opening the dialog to avoid a delay.  
+  
+`unmount(): void`  
+Unmounts the dialog from the DOM.  
+  
+`remount(): Promise<void>`  
+Unmounts and mounts the dialog again.  
+The render functions in the options object will be called again.  
+May cause a flickering effect due to the rendering delay.  
+  
+`isOpen(): boolean`  
+Returns `true` if the dialog is open, else `false`.  
+  
+`isMounted(): boolean`  
+Returns `true` if the dialog is mounted, else `false`.  
+  
+`destroy(): void`  
+Destroys the dialog.  
+Removes all listeners and unmounts the dialog by default.  
+  
+`static getCurrentDialogId(): string`  
+Static method that returns the ID of the currently open dialog.  
+Needs to be called without creating an instance of the class.  
+  
+`static getOpenDialogs(): string[]`  
+Static method that returns an array of the IDs of all open dialogs.  
+Needs to be called without creating an instance of the class.  
+  
+<details><summary><b>Example - click to view</b></summary>
+
+```ts
+import { Dialog } from "@sv443-network/userutils";
+
+const fooDialog = new Dialog({
+  id: "foo-dialog",
+  width: 400,
+  height: 300,
+  renderHeader() {
+    const header = document.createElement("div");
+    header.textContent = "This is the header";
+    return header;
+  },
+  renderBody() {
+    const body = document.createElement("div");
+    body.textContent = "This is the body";
+    return body;
+  },
+  renderFooter() {
+    const footer = document.createElement("div");
+    footer.textContent = "This is the footer";
+    return footer;
+  },
+  closeOnBgClick: true,
+  closeOnEscPress: true,
+  destroyOnClose: false,
+  unmountOnClose: true,
+  removeListenersOnDestroy: true,
+  small: false,
+  verticalAlign: "center",
+  strings: {
+    closeDialogTooltip: "Click to close",
+  },
+  dialogCss: getMyCustomDialogCss(),
+});
+
+fooDialog.on("close", () => {
+  console.log("Dialog closed");
+});
+
+fooDialog.open();
+```
+
+</details>
+
+<br>
+
+### NanoEmitter
+Usage:  
+```ts
+new NanoEmitter<TEventMap = EventsMap>(options?: NanoEmitterOptions): NanoEmitter<TEventMap>
+```  
+  
+A class that provides a minimalistic event emitter with a tiny footprint powered by [nanoevents.](https://npmjs.com/package/nanoevents)  
+The `TEventMap` generic is used to define the events that can be emitted and listened to.  
+  
+The intention behind this class is to extend it in your own classes to provide a simple event system.  
+You can also just create an instance and export it to use it as standalone event emitters throughout your project.  
+  
+The options object has the following properties:
+| Property | Description |
+| :-- | :-- |
+| `publicEmit?: boolean` | (Optional)  If set to true, allows emitting events through the public method `emit()` (`false` by default). |
+  
+Methods:  
+`on<K extends keyof TEventMap>(event: K, listener: TEventMap[K]): void`  
+Registers a listener function for the given event.  
+May be called multiple times for the same event.  
+  
+`once<K extends keyof TEventMap>(event: K, listener: TEventMap[K]): void`  
+Registers a listener function for the given event that will only be called once.
+  
+`emit<K extends keyof TEventMap>(event: K, ...args: Parameters<TEventMap[K]>): boolean`  
+Emits an event with the given arguments from outside the class instance if `publicEmit` is set to `true`.  
+If `publicEmit` is set to `true`, this method is public and can be called from outside the class and will return `true`.  
+If not, it is protected and will return `false`.  
+  
+`unsubscribeAll(): void`  
+Removes all listeners from all events.  
+  
+<details><summary><b>Example - click to view</b></summary>
+
+```ts
+import { NanoEmitter } from "@sv443-network/userutils";
+
+interface MyEvents {
+  foo: (bar: string) => void;
+  baz: (qux: number) => void;
+}
+
+class MyClass extends NanoEmitter<MyEvents> {
+  constructor() {
+    super({
+      // allow emitting events from outside the class
+      publicEmit: true,
+    });
+    this.on("foo", (bar) => {
+      console.log("foo event:", bar);
+    });
+    this.once("baz", (qux) => {
+      console.log("baz event:", qux);
+    });
+  }
+
+  doStuff() {
+    this.emit("foo", "hello");
+    this.emit("baz", 42);
+    this.emit("foo", "world");
+    this.emit("baz", 69);
+  }
+}
+
+const myInstance = new MyClass();
+myInstance.doStuff();
+
+myInstance.emit("foo", "from da outside");
+
+myInstance.unsubscribeAll();
+```
+
+</details>
+
+<br>
 
 ### autoPlural()
 Usage:  

+ 70 - 0
lib/NanoEmitter.ts

@@ -0,0 +1,70 @@
+import { createNanoEvents, type DefaultEvents, type EventsMap, type Unsubscribe } from "nanoevents";
+
+export interface NanoEmitterOptions {
+  /** If set to true, allows emitting events through the public method emit() */
+  publicEmit: boolean;
+}
+
+/** Class that can be extended or instantiated by itself to create an event emitter with helper methods and a strongly typed event map */
+export class NanoEmitter<TEvtMap extends EventsMap = DefaultEvents> {
+  protected readonly events = createNanoEvents<TEvtMap>();
+  protected eventUnsubscribes: Unsubscribe[] = [];
+  protected emitterOptions: NanoEmitterOptions;
+
+  constructor(options: Partial<NanoEmitterOptions> = {}) {
+    this.emitterOptions = {
+      publicEmit: false,
+      ...options,
+    };
+  }
+
+  /** Subscribes to an event - returns a function that unsubscribes the event listener */
+  public on<TKey extends keyof TEvtMap>(event: TKey | "_", cb: TEvtMap[TKey]) {
+    // eslint-disable-next-line prefer-const
+    let unsub: Unsubscribe | undefined;
+
+    const unsubProxy = () => {
+      if(!unsub)
+        return;
+      unsub();
+      this.eventUnsubscribes = this.eventUnsubscribes.filter(u => u !== unsub);
+    };
+
+    unsub = this.events.on(event, cb);
+
+    this.eventUnsubscribes.push(unsub);
+    return unsubProxy;
+  }
+
+  /** Subscribes to an event and calls the callback or resolves the Promise only once */
+  public once<TKey extends keyof TEvtMap>(event: TKey | "_", cb?: TEvtMap[TKey]): Promise<Parameters<TEvtMap[TKey]>> {
+    return new Promise((resolve) => {
+      // eslint-disable-next-line prefer-const
+      let unsub: Unsubscribe | undefined;
+
+      const onceProxy = ((...args: Parameters<TEvtMap[TKey]>) => {
+        unsub!();
+        cb?.(...args);
+        resolve(args);
+      }) as TEvtMap[TKey];
+
+      unsub = this.on(event, onceProxy);
+    });
+  }
+
+  /** Emits an event on this instance - Needs `publicEmit` to be set to true in the constructor! */
+  public emit<TKey extends keyof TEvtMap>(event: TKey, ...args: Parameters<TEvtMap[TKey]>) {
+    if(this.emitterOptions.publicEmit) {
+      this.events.emit(event, ...args);
+      return true;
+    }
+    return false;
+  }
+
+  /** Unsubscribes all event listeners */
+  public unsubscribeAll() {
+    for(const unsub of this.eventUnsubscribes)
+      unsub();
+    this.eventUnsubscribes = [];
+  }
+}

+ 14 - 2
package-lock.json

@@ -1,13 +1,16 @@
 {
   "name": "@sv443-network/userutils",
-  "version": "6.3.0",
+  "version": "7.0.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@sv443-network/userutils",
-      "version": "6.3.0",
+      "version": "7.0.0",
       "license": "MIT",
+      "dependencies": {
+        "nanoevents": "^9.0.0"
+      },
       "devDependencies": {
         "@changesets/cli": "^2.26.2",
         "@types/express": "^4.17.19",
@@ -4557,6 +4560,15 @@
         "thenify-all": "^1.0.0"
       }
     },
+    "node_modules/nanoevents": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/nanoevents/-/nanoevents-9.0.0.tgz",
+      "integrity": "sha512-X8pU7IOpgKXVLPxYUI55ymXc8XuBE+uypfEyEFBtHkD1EX9KavYTVc+vXZHFyHKzA1TaZoVDqklLdQBBrxIuAw==",
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
     "node_modules/natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",

+ 4 - 0
package.json

@@ -6,6 +6,7 @@
   "main": "dist/index.js",
   "module": "dist/index.mjs",
   "types": "dist/lib/index.d.ts",
+  "type": "module",
   "scripts": {
     "lint": "tsc --noEmit && eslint .",
     "build-types": "tsc --emitDeclarationOnly --declaration --outDir dist",
@@ -37,6 +38,9 @@
     "url": "https://github.com/Sv443-Network/UserUtils/issues"
   },
   "homepage": "https://github.com/Sv443-Network/UserUtils",
+  "dependencies": {
+    "nanoevents": "^9.0.0"
+  },
   "devDependencies": {
     "@changesets/cli": "^2.26.2",
     "@types/express": "^4.17.19",