drab
Version:
Interactivity for You
160 lines (159 loc) • 6.31 kB
JavaScript
import { Announcer } from "../announcer/index.js";
import { validate } from "../util/validate.js";
export const Lifecycle = (Super = HTMLElement) => class Lifecycle extends Super {
/** To clean up event listeners added to `document` when the element is removed. */
#listenerController = new AbortController();
constructor(...args) {
super(...args);
}
safeListener(type, listener, target = document.body, options = {}) {
options.signal = this.#listenerController.signal;
target.addEventListener(type, listener, options);
}
/**
* Passed into `queueMicrotask` in `connectedCallback`.
* It is overridden in each component that needs to run `connectedCallback`.
*
* The reason for this is to make these elements work better with frameworks like Svelte.
* For SSR this isn't necessary, but when client side rendering, the HTML within the
* custom element isn't available before `connectedCallback` is called. By waiting until
* the next microtask, the HTML content is available---then for example, listeners can
* be attached to elements inside.
*/
mount() { }
/** Called when custom element is added to the page. */
connectedCallback() {
queueMicrotask(() => this.mount());
}
/**
* Passed into `disconnectedCallback`, since `Base` needs to run `disconnectedCallback` as well. It is overridden in each element that needs to run `disconnectedCallback`.
*/
destroy() { }
/** Called when custom element is removed from the page. */
disconnectedCallback() {
this.destroy();
this.#listenerController.abort();
}
};
/**
* By default, `trigger`s are selected via the `data-trigger` attribute.
* Alternatively, you can set the `trigger` attribute to a CSS selector to
* change the default selector from `[data-trigger]` to a selector of your
* choosing. This can be useful if you have multiple elements within one another.
*
* Each element can have multiple `trigger`s.
*/
export const Trigger = (Super = HTMLElement) => class Trigger extends Super {
constructor(...args) {
super(...args);
}
/**
* Event for the `trigger` to execute.
*
* For example, set to `"mouseover"` to execute the event when the user hovers the mouse over the `trigger`, instead of when they click it.
*
* @default "click"
*/
get event() {
return (this.getAttribute("event") ?? "click");
}
set event(value) {
this.setAttribute("event", value);
}
triggers(instance = HTMLElement) {
const triggers = this.querySelectorAll(this.getAttribute("trigger") ?? "[data-trigger]");
for (const trigger of triggers)
validate(trigger, instance);
return triggers;
}
listener(listenerOrType, listenerOrOptions, optionsMaybe) {
let type;
let listener;
let options;
if (typeof listenerOrType === "function") {
// (listener, options?)
type = this.event;
listener = listenerOrType;
options = listenerOrOptions;
}
else {
// (type, listener, options?)
type = listenerOrType;
listener = listenerOrOptions;
options = optionsMaybe;
}
for (const trigger of this.triggers()) {
trigger.addEventListener(type, listener, options);
}
}
};
/**
* By default, `content` is selected via the `data-content` attribute.
* Alternatively, you can set the `trigger` to a CSS selector to change
* the default selector from `[data-trigger]` to a selector of your choosing.
* This can be useful if you have multiple elements within one another.
*
* Each element can only have one `content`.
*/
export const Content = (Super = HTMLElement) => class Content extends Super {
constructor(...args) {
super(...args);
}
content(instance = HTMLElement) {
return validate(this.querySelector(this.getAttribute("content") ?? "[data-content]"), instance);
}
/**
* Finds the `HTMLElement | HTMLTemplateElement` via the `swap` selector and
* swaps `this.content()` with the content of the element found.
*
* @param revert Wait time (ms) before swapping back, set to `false` to not revert.
* default: `800`
*/
swap(revert = 800) {
/** The swap element, used to hold the replacement contents. */
const swapTarget = this.querySelector(this.getAttribute("swap") ?? "[data-swap]");
if (swapTarget) {
/** A copy of the content currently in `this.getContent()`. */
const currentContent = this.content().childNodes;
/**
* The contents of the swap element, set based on whether the
* swap is a `template` or not.
*/
const placeholder = [];
// Set the placeholder with the `swap` content, then replace the
// swap content with the `currentContent`
if (swapTarget instanceof HTMLTemplateElement) {
// use `content` since it's a `template` element
placeholder.push(swapTarget.content.cloneNode(true));
swapTarget.content.replaceChildren(...currentContent);
}
else {
// not a `template`, replace children directly
placeholder.push(...swapTarget.childNodes);
swapTarget.replaceChildren(...currentContent);
}
// finally, set the content to the contents of the placeholder
this.content().replaceChildren(...placeholder);
if (revert) {
// wait and then run again to swap back
setTimeout(() => this.swap(0), revert);
}
}
}
};
export const Announce = (Super = HTMLElement) => class Announce extends Super {
/**
* A single `Announcer` element to share between all drab elements to announce
* interactive changes.
*/
static #announcer = Announcer.init();
constructor(...args) {
super(...args);
}
/**
* @param message message to announce to screen readers
*/
announce(message) {
Announce.#announcer.announce(message);
}
};