UNPKG

drab

Version:

Interactivity for You

291 lines (259 loc) 8.98 kB
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); } };