drab
Version:
Interactivity for You
291 lines (259 loc) • 8.98 kB
text/typescript
import { Announcer } from "../announcer/index.js";
import { validate } from "../util/validate.js";
export interface TriggerAttributes {
trigger?: string;
}
export interface ContentAttributes {
content?: string;
swap?: string;
}
export type Constructor<T> = new (...args: any[]) => T;
export const Lifecycle = <T extends Constructor<HTMLElement>>(
Super = HTMLElement as T,
) =>
class Lifecycle extends Super {
/** To clean up event listeners added to `document` when the element is removed. */
#listenerController = new AbortController();
constructor(...args: any[]) {
super(...args);
}
/**
* Wrapper around `addEventListener` that ensures when the element is
* removed from the DOM, these event listeners are cleaned up.
*
* @param type Event listener type - ex: `"keydown"`
* @param listener Listener to add to the target.
* @param target Event target to add the listener to - defaults to `document.body`.
* @param options Other options sans `signal`.
*/
safeListener<T extends keyof HTMLElementEventMap>(
type: T,
listener: (this: HTMLElement, event: HTMLElementEventMap[T]) => any,
element?: HTMLElement,
options?: AddEventListenerOptions,
): void;
safeListener<T extends keyof DocumentEventMap>(
type: T,
listener: (this: Document, event: DocumentEventMap[T]) => any,
document: Document,
options?: AddEventListenerOptions,
): void;
safeListener<T extends keyof WindowEventMap>(
type: T,
listener: (this: Window, event: WindowEventMap[T]) => any,
window: Window,
options?: AddEventListenerOptions,
): void;
safeListener(
type: string,
listener: EventListenerOrEventListenerObject,
target: EventTarget = document.body,
options: AddEventListenerOptions = {},
) {
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();
}
};
type Listener<T extends keyof HTMLElementEventMap> = (
this: HTMLElement,
e: HTMLElementEventMap[T],
) => any;
/**
* 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 = <T extends Constructor<HTMLElement>>(
Super = HTMLElement as T,
) =>
class Trigger extends Super {
constructor(...args: any[]) {
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(): keyof HTMLElementEventMap {
return (
(this.getAttribute("event") as keyof HTMLElementEventMap) ?? "click"
);
}
set event(value) {
this.setAttribute("event", value);
}
/**
* @param instance The instance of the desired element to validate against,
* ex: `HTMLButtonElement`. Defaults to `HTMLElement`.
* @returns All of the elements that match the `trigger` selector.
* @default this.querySelectorAll("[data-trigger]")
*/
triggers<T extends HTMLElement>(instance: Constructor<T>): NodeListOf<T>;
triggers(): NodeListOf<HTMLElement>;
triggers(instance = HTMLElement) {
const triggers = this.querySelectorAll(
this.getAttribute("trigger") ?? "[data-trigger]",
);
for (const trigger of triggers) validate(trigger, instance);
return triggers;
}
/**
* @param listener Listener to attach to all of the `trigger` elements.
* @param options
*/
listener<T extends keyof HTMLElementEventMap>(
listener: Listener<T>,
options?: AddEventListenerOptions,
): void;
/**
* @param type Event type.
* @param listener Listener to attach to all of the `trigger` elements.
* @param options
*/
listener<T extends keyof HTMLElementEventMap>(
type: T,
listener: Listener<T>,
options?: AddEventListenerOptions,
): void;
listener<T extends keyof HTMLElementEventMap>(
listenerOrType: Listener<T> | T,
listenerOrOptions?: Listener<T> | AddEventListenerOptions,
optionsMaybe?: AddEventListenerOptions,
): void {
let type: keyof HTMLElementEventMap;
let listener: Listener<any>;
let options: AddEventListenerOptions | undefined;
if (typeof listenerOrType === "function") {
// (listener, options?)
type = this.event;
listener = listenerOrType;
options = listenerOrOptions as AddEventListenerOptions;
} else {
// (type, listener, options?)
type = listenerOrType;
listener = listenerOrOptions as Listener<T>;
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 = <T extends Constructor<HTMLElement>>(
Super = HTMLElement as T,
) =>
class Content extends Super {
constructor(...args: any[]) {
super(...args);
}
/**
* @param instance The instance of the desired element to validate against,
* ex: `HTMLDialogElement`. Defaults to `HTMLElement`.
* @returns The element that matches the `content` selector.
* @default this.querySelector("[data-content]")
*/
content<T extends HTMLElement>(instance: Constructor<T>): T;
content(): HTMLElement;
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: number | false = 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: Node[] = [];
// 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 = <T extends Constructor<HTMLElement>>(
Super = HTMLElement as T,
) =>
class Announce extends Super {
/**
* A single `Announcer` element to share between all drab elements to announce
* interactive changes.
*/
static #announcer = Announcer.init();
constructor(...args: any[]) {
super(...args);
}
/**
* @param message message to announce to screen readers
*/
announce(message: string) {
Announce.#announcer.announce(message);
}
};