jazzypants1989 / jessquery

Modern JavaScript is pretty good, but typing `document.querySelector()` is a pain. This is a tiny library that makes DOM manipulation easy. jQuery is around 30kb, while this is only around 3.5kb. Lots of JSDoc comments so it's self-documenting and works great with TypeScript.
119 stars 1 forks source link

jessquery

jessquery is a lightweight wrapper around the DOM API that offers the intuitive elegance of jQuery, but streamlined for the modern web.

Feel like a 🦕 for still using jQuery? Wish that it didn't bloat up your bundle size like a 🐖? Want something 🆕 and ✨?

Rekindle your love for method chaining-- now in a lightweight, type-safe package! With 43 custom methods that are sneakily powerful, jessquery helps you seamlessly handle asynchronous tasks, customize error behaviors, and ensure your DOM operations always execute in order. And, the best part? 🏎️💨

Library Size before gzip Size after gzip
jQuery 88.3kb 31.7kb
jessquery 8.82kb 3.76kb

And, if that's too big for you, you can use our scrawny kid brother Droxy instead. He's only 2kb after gzip!

It's only 3.76kb! I swear! This badge proves it. npm version

Basic Usage

// Most things follow the DOM API closely with slightly different names.
// But, now you can chain them together
// They will always execute in order!
const fadeIn = [{ opacity: 0 }, { opacity: 1 }] // WAAPI keyframes
const fadeOut = [{ opacity: 1 }, { opacity: 0 }] // WAAPI keyframes
const animatedText = $$(".animated-text") // $$ ≈ querySelectorAll, use $ for querySelector

// <span hidden class="animated-text"></span>
// <span hidden class="animated-text"></span>
animatedText
  .addClass("special")
  .wait(1000) // Will not appear for one second
  .toggle("hidden")
  .text(
    `<p>
      In two seconds, every element matching the 'animated-text' class
      will fade in and out twice then disappear.
    </p>`
  )
  .wait(2000)
  .transition(fadeIn, 1000)
  .transition(fadeOut, 1000)
  .transition(fadeIn, 1000)
  .transition(fadeOut, 1000)
  .purge() // All `.animated-text` elements will be removed from the DOM

Installation

You can install it via NPM, PNPM, Yarn, or Bun just like anything else on NPM.

npm install jessquery
pnpm install jessquery
yarn add jessquery
bun install jessquery

Or, since it's so small, you can just use a CDN like the good, old days. The big problem with this is that you lose the types and the JSDoc annotations. I keep those in the d.ts file to keep the file size small, but I recently learned that gzip takes care of that for you. So, I'll probably change that in the future. For now, you can just use the index.d.ts file in your project if you want the types without installing the package.

<script src="https://esm.sh/jessquery"></script>
<script src="https://unpkg.com/jessquery"></script>

Demo and Key Concepts

jessquery is quite different from jQuery, but it makes sense once you understand the rules. The concurrent chaining makes things a bit more complex. The key is understanding that each $() or $$() call is representative of a single queue-- not necessarily the elements that are being manipulated. It's a bit like PrototypeJS mixed with the async flow of something like RxJS.

The magic sauce here is that everything is a proxy, so you can still use the full DOM API if your use case isn't covered by one of the methods. So, if you forget about the .css operator and use .style instead when using $(), it will just work. The NodeList that you get from $$() is automatically turned into an array so you can use array methods on it like .map() or .filter().

This is the benefit of using proxies, but I'm curious if this will scale well as they bring a tiny bit of overhead. This might get problematic in large applications, but I'm probably just being paranoid. I welcome anyone to do some tests! 😅

Here's a Stackblitz Playground if you want to try it out. The demo that will load in has an extremely long chain showing the mutability that a standard DomProxy exhibits. To see how an error is thrown when that proxy is fixed in place, simply add a true argument to the $() call like this: const container = $(".container", true).

TypeScript

Everything is fully type-safe, but there's no way for the $() and $$() functions to infer the type of the element you're selecting unless it's a tag name. Things like $$('input') will always be fully inferred even if you map over the individual elements in the collection-- in that case, each element would automatically become an HTMLInputElement. However, if you select a class or id, the type will always be HTMLElement unless you specify the type yourself like this:

const button = $<HTMLButtonElement>(".button")

const coolInputs = $$<HTMLInputElement>(".cool-inputs")

The Rules

I wrote a lot, but the main idea is that everything should be predictable. You probably only need to read the bold parts unless you start doing a lot of crazy DOM manipulation that operates on multiple elements at once while using the same variables for everything. If you're just doing simple stuff, you can probably just ignore the rest. 👌

  1. Use $() to build a queue that operates on a single element-- a DomProxy. However, if you use a method like pickAll() or kids(), you will switch to a DomProxyCollection with multiple elements.
  2. Use $$() to build a queue that operates on multiple elements at once-- a DomProxyCollection. However, if you use a method like pick() or parent() and there is only one element in the collection, you will switch to a DomProxy with a single element.
  3. Every DomProxy is mutable unless it was created with a fixed argument set to true. If you store it in a variable and you change the element with a method like next() or siblings(), any event handlers that use that variable for DOM manipulation will now operate on the new element unless you use the refresh() method to reset the proxy to its original state.
  4. ALL jessquery custom methods can be chained together. Each method will operate on the element(s) held in the proxy at the time the function is called. If you switch context multiple times, it can get confusing. Try to only switch "element context" once per method chain. If you do not want your proxy to be mutable, set the fixed argument to true.
  5. ALL jessquery custom methods are setters that return the proxy. If you need to check the value of something, just use the DOM API directly (textContent instead of text(), for example). This also helps to differentiate between set and get operations.
  6. ALL DOM API's can be used, but they MUST COME LAST within a single chain. You can always start a new chain if you need to. You can even use the same variable-- you just need to know that function won't be executed until the previous chain finishes or hits a microtask.
  7. Each variable tied to a single $() or $$() call gets its own queue which runs every function sequentially, but the chains are executed concurrently. So, you can have multiple chains operating on the same element. Be careful of race conditions!
  8. All chains are begun in the order they are found in the script, but they await any microtasks or promises found before continuing. If you need to do things concurrently, just make a new variable so you get a new queue.
  9. Synchronous tasks are always executed as soon as possible, but not until their turn is reached in the queue. If they are preceded by an async task, they will be added to the queue, executed in order, and any promises in their arguments will be awaited before the function is called.
  10. Each method is blocking, so if you use the same variable for event handlers, you will block the event handler from firing until that function is finished. This is particularly problematic if that chain has any wait() calls or long animations.

Generally, just try to keep each discrete chain of DOM operations for a single element together, and try to use a new variable for any event handlers. I mean, the whole point of this library is that $() and $$() are really easy to type, and you only need to worry about it when things aren't behaving the way you expect. If anything gets too hard, you can also use the defer() and wait() methods to let the DOM catch up while you re-evaluate your life choices. 😅

Code Walkthrough

I recorded a three hour long video explaining the code.

Advanced Usage

DomProxy Lifecycle

import { $, $$, promisify, setErrorHandler } from "jessquery"

// Single element.
const display = $(".display")

// Multiple elements.
const dynamicSpans = $$(".dynamic-spans")

// Dynamically create an element.
$(`<h1>I'm #1</h1>`).moveTo(".container", { position: "prepend" }) // append is the default

// You can completely replace the element(s). The proxy is what's stable-- not the element(s) inside.
// These changes are permanent as they operate on the DOM directly.
const button = $(".button")
button.html(`<h3>I am not a button</h3>`, true)
// The second argument determines if the parent element should be replaced as well.

// You can also use become() to transform the elements into something from elsewhere in the DOM.
const buttons = $(".button")
buttons.become($("#other-button"))
// Each button will now be a deep clone of the other-button element. (no event handlers)

// moveTo, cloneTo, and attach allow you to move elements around the DOM.
// If you don't want to move things permanently, you can:
//  - use cloneTo to make a copy of the current element(s) and attach it to another element
//  - set the mode to `clone` on attach to copy another element from elsewhere in the DOM
// However, this will not be able to move event handlers assigned to the original element(s).
const coolDiv = $(".cool-div")
coolDiv.cloneTo(".container", { position: "prepend" })

// But remember, you can use the same proxy to operate on different elements over time.
coolDiv.next().text("I'm the next element!")
// The coolDiv proxy will no longer operate on elements with the class "cool-div"

// When in doubt, just use refresh() to reset the proxy to its original state.
// /**
//  * <div class="container">
//  *   <p id="ME">1</p>
//  *   <p>2</p>
//  *   <p>3</p>
//  * </div>
//  */
const ME = $("#ME")
ME.do((el) => console.log(el.textContent)) // 1
ME.next().do((el) => console.log(el.textContent)) // 2
ME.parent().do((el) => console.log(el.textContent)) // 123
ME.refresh().do((el) => console.log(el.textContent)) // 1

Conditional Logic

const button = $(".button")
const display = $(".display")

// My first suggestion is to use a simple ternary with the DOM API.
button.on("click", () => {
  display.text(display.textContent === "Click me!" ? "Clicked!" : "Click me!")
})

// But, the if method allows you to chain things together.
button.on("click", () => {
  display
    .if({
      is: (el) => el.textContent === "Click me!",
      then: (el) => el.text("Clicked!").css("color", "green"),
      or: (el) => el.text("Click me!").css("color", "red"),
    })
    .wait(1000)
    .if({
      is: (el) => el.textContent === "Clicked!",
      then: (el) => el.text("Click me!").css("color", "red"),
      or: (el) => el.text("Clicked!").css("color", "green"),
    })
})

// `takeWhile` actually changes the elements held within the proxy.
const buttons = $$(".button")

buttons
  .takeWhile((el) => el.textContent !== "Click me!")
  .css("color", "red") // Only the buttons that don't say "Click me!" will be red.
  .wait(1000)
  .css("color", "blue") // Those buttons will turn blue after one second.
const buttons = $$(".button")

buttons
  .takeWhile((el) => el.textContent !== "Click me!")
  .css("color", "red") // Only the buttons that don't say "Click me!" will have red text.
  .refresh()
  .css("background-color", "blue") // All buttons will be blue.

Async Flow

// You can do anything you want for however long you want.
async function fetchData() {
  const response = await fetch("https://api.github.com/users/jazzypants1989")
  const data = await response.json()
  return data.name
}

// Every promise is resolved automatically
button.on("click", () => {
  // The next function never runs until the previous one is finished.
  display
    .text(fetchData()) // No await, no try/catch, no problem!
    .css(color, display.textContent === "Jesse Pence" ? "green" : "red")
  // Each proxy has full access to the DOM API-- useful for conditional logic.

  display
    .do(async (el) => {
      // For more complex async logic, you can use the do method.
      // It will hold the queue indefinitely while you do whatever you want.
      el.text("Loading...")
      const response = await fetch(
        "https://api.github.com/users/jazzypants1989"
      )
      const data = await response.json()
      el.text(data.name).css("color", "green")
      await new Promise((resolve) => setTimeout(resolve, 3000))
    })
    .text(
      "This will be green and replace the element's text, but only after three seconds of waiting!"
    )
})

Helper Functions: promisify and setErrorHandler

// It's great for when you forget to cover all conditions like this troubling example,
// The next function will try to wait (five seconds by default).
// If it still hasn't resolved, the chain will keep moving
// (while passing an error to the error handler).
const pollAPIUntilItWorks = promisify(
  (resolve, reject) => {
      const response = await fetch("https://someCrappyAPI.com")
      if (response.ok) {
        resolve(response.json()) // you can just pass this promise through
      }
      // No reject, no problem! It'll be covered by the default error handler.
    },
  {
    timeout: 10000,
    // You can set the timeout to whatever you want.
    interval: 500,
    // It will usually only try the function once, but you can set an interval to retry.
    url: "https://someCrappyAPI.com",
    // You can pass extra metadata to the error handler.
  }
)

// The default error handler catches most errors and promisify rejections
// It simply logs using console.error, but you can use setErrorHandler to override this.
setErrorHandler((err, context) => {
  sendErrorToAnalytics(err, context)
})

display.on("mouseover", () => {
  display
    .html(`<pre>JSON.stringify(${pollAPIUntilItWorks()}, null, 2)</pre>`)
    .attach(
      `This will wait for ten seconds, but it will still show up if the API fails!`
    )
})

AJAX

const fetchOptions = {
  // You can use the same options as fetch, but with loads of extra features like:
  // Automatic Retries with exponential backoff. (default is 0)
  retries: 3,
  // The first retry will be in one second, then two seconds, then four seconds...
  retryDelay: 1000,
  // You can set up an error handler that will be called if the fetch still fails after all retries.
  onError: (err) => sendFetchErrorToAnalytics(err).
  // Or, a success handler that will reflect the DOM AFTER the element has been updated.
  onSuccess: () => dynamicSpans.attach("<h6>Data Loaded!</h6>"),
  // This custom loading message will replace the element's text while it waits.
  onWait: () => dynamicSpans.text("Hold your horses! I'm loading data!")
  // But, only if it doesn't load within one second. (default is 250ms and no message)
  waitTime: 1000,
  // Everything is sanitized by default, but you can turn it off if you want.
  runScripts: true,
  sanitize: false,
  // the full range of fetch options (request) are still supported.
  headers: {
    "Cool-Header": "Cool-Value",
  },
}

// You can nest things as deep as you want (if you like making things confusing).
dynamicSpans.fromJSON(
  "https://jessepence.com/api/cool-json",
  (el, json) => {
    el.html(
      `<h2>${json.name}</h2>
       <p>${json.bio}</p>
    `
    )
      .wait(5000)
      .fromHTML("/api/extended-bio", fetchOptions)
      .attach(
        "<h2>Enough about me, I'll replace this with a cool stream in 5 seconds!</h2>"
      )
      .wait(5000)
      .fromStream("/api/cool-stream", fetchOptions)
  },
  fetchOptions
)
// This will automatically serialize the form
// It will send it to the action attribute if it exists (The page's URL if not)
$("#bigForm").send()

// You can also customize each aspect of the request if you want.
// EVERYTHING is optional. You can just pass a URL if you want.
$("#otherSubmitButton").on("click", (event) => {
  $$("#bigForm").send({
    // Just pass in the event to prevent default submission
    event,
    // If no URL, it will use the formaction attribute or any ancestor form's action that exists.
    url: "/api/cool-endpoint",
    // If no body, the form's data would be used. If no form, the value/textContent would be used.
    body: { cool: "data" },
    // POST is the default
    method: "PUT",
    // You still get all the extra event hooks and options.
    onWait: () => console.log("Waiting for the server to respond..."),
    // And, of course, all the normal fetch options as well.
    headers: {
      "Cool-Header": "Cool-Value",
    },
  })
})
// (this will send multiple fetches ($$) though. No caching or batching... yet)

Interfaces

Table of Contents

$()

$("#button")
  .on("click", () => console.log("Clicked!"))
  .css("color", "purple")
  .wait(1000)
  .css("color", "lightblue")
  .text("Click me!").style.backgroundColor = "lightGreen" // This will work, but only because it's the last thing in the chain.
// It's also important to note that the style method call is not queued, so it will happen before everything else.

$$()

$$(".buttons")
  .on("click", () => console.log("Clicked!"))
  .css("color", "purple")
  .wait(1000)
  .css("color", "lightblue")
  .text("Click me!").style.backgroundColor = "lightGreen" // This will work, but only because it's the last thing in the chain.
// It's also important to note that the style method call is not queued, so it will happen before everything else.

setErrorHandler

Sets an error handler that will be called when an error occurs somewhere in JessQuery. The default behavior is to just log it to the console. You can override this behavior with this method to do something else (or nothing... no judgement here! 😉)

promisify

DomProxy

A proxy covering a single HTML element that allows you to chain methods sequentially (including asynchronous tasks) and then execute them one after the other. It includes 43 of these custom methods, but you can still use the full DOM API if you need to.

DomProxy Methods

DomProxy.on

on(ev: string, fn: EventListenerOrEventListenerObject): DomProxy

DomProxy.once

once(ev: string, fn: EventListenerOrEventListenerObject): DomProxy

DomProxy.off
DomProxy.delegate
DomProxy.html
DomProxy.sanitize
DomProxy.text
DomProxy.val
DomProxy.css
DomProxy.addStyleSheet
DomProxy.addClass
DomProxy.removeClass
DomProxy.toggleClass
DomProxy.set
DomProxy.unset
DomProxy.toggle
DomProxy.data
DomProxy.attach
DomProxy.cloneTo
DomProxy.moveTo
DomProxy.become
DomProxy.purge
DomProxy.send
DomProxy.do
DomProxy.defer
DomProxy.fromJSON
DomProxy.fromHTML
DomProxy.fromStream
DomProxy.transition
DomProxy.wait
DomProxy.next
DomProxy.prev
DomProxy.first
DomProxy.last
DomProxy.parent
DomProxy.ancestor
DomProxy.pick
DomProxy.pickAll
DomProxy.siblings
DomProxy.kids
DomProxy.if
DomProxy.takeWhile
DomProxy.refresh

DomProxyCollection

A proxy covering a collection of HTML elements that allows you to chain methods sequentially (including asynchronous tasks) and then execute them one after the other. It includes 43 of these custom methods, but you can still use the full DOM API if you need to.

DomProxyCollection Methods

DomProxyCollection.on
DomProxyCollection.once
DomProxyCollection.off
DomProxyCollection.delegate
DomProxyCollection.html
DomProxyCollection.sanitize
DomProxyCollection.text
DomProxyCollection.val
DomProxyCollection.css
DomProxyCollection.addStyleSheet
DomProxyCollection.addClass
DomProxyCollection.removeClass
DomProxyCollection.toggleClass
DomProxyCollection.set
DomProxyCollection.unset
DomProxyCollection.toggle
DomProxyCollection.data
DomProxyCollection.attach
DomProxyCollection.cloneTo
DomProxyCollection.moveTo
DomProxyCollection.become
DomProxyCollection.purge
DomProxyCollection.send
DomProxyCollection.do
DomProxyCollection.defer
DomProxyCollection.fromJSON
DomProxyCollection.fromHTML
DomProxyCollection.fromStream
DomProxyCollection.transition
DomProxyCollection.wait
DomProxyCollection.next
DomProxyCollection.prev
DomProxyCollection.first
DomProxyCollection.last
DomProxyCollection.parent
DomProxyCollection.ancestor
DomProxyCollection.pick
DomProxyCollection.pickAll
DomProxyCollection.siblings
DomProxyCollection.kids
DomProxyCollection.if
DomProxyCollection.takeWhile
DomProxyCollection.refresh

FetchOptions

Contributing

If you have any ideas for new features or improvements, feel free to open an issue or a PR. I'm always open to suggestions! I started this as a bit of a joke, but I think it turned into something pretty useful. I'm sure there are a lot of things that could be improved, so I welcome any and all feedback.