UNPKG

drab

Version:

Interactivity for You

160 lines (159 loc) 6.31 kB
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); } };